diff --git a/.oxlintrc.json b/.oxlintrc.json index 240d63041..df19b2d02 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -61,6 +61,7 @@ "overeng/exports-first": "warn", "overeng/jsdoc-require-exports": "warn", "typescript/consistent-type-imports": "warn", + "overeng/no-raw-otel-primitives": "off", "typescript/consistent-type-definitions": "off", "typescript/no-deprecated": "error", "typescript/no-unsafe-type-assertion": "off", @@ -127,6 +128,7 @@ "files": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/test/**"], "rules": { "overeng/named-args": "off", + "overeng/no-raw-otel-primitives": "off", "unicorn/no-array-sort": "off", "unicorn/consistent-function-scoping": "off", "require-yield": "off" @@ -150,6 +152,7 @@ "overeng/exports-first": "off", "overeng/jsdoc-require-exports": "off", "overeng/named-args": "off", + "overeng/no-raw-otel-primitives": "off", "unicorn/consistent-function-scoping": "off" } }, @@ -165,6 +168,33 @@ "overeng/no-external-imports": "off" } }, + { + "files": ["packages/@overeng/*/src/**/*.ts", "packages/@overeng/*/src/**/*.tsx"], + "rules": { + "overeng/no-raw-otel-primitives": "error" + } + }, + { + "files": [ + "packages/@overeng/otel-contract/src/**", + "packages/@overeng/utils-dev/src/otelite/**", + "packages/@overeng/*/src/**/*.test.ts", + "packages/@overeng/*/src/**/*.test.tsx", + "packages/@overeng/*/src/**/*.spec.ts", + "packages/@overeng/*/src/**/*.spec.tsx", + "packages/@overeng/*/src/**/*.unit.test.ts", + "packages/@overeng/*/src/**/*.unit.test.tsx", + "packages/@overeng/*/src/**/*.integration.test.ts", + "packages/@overeng/*/src/**/*.integration.test.tsx", + "packages/@overeng/*/src/**/*.e2e.test.ts", + "packages/@overeng/*/src/**/*.e2e.test.tsx", + "packages/@overeng/*/src/**/*.gen.ts", + "packages/@overeng/*/src/**/*.gen.tsx" + ], + "rules": { + "overeng/no-raw-otel-primitives": "off" + } + }, { "files": ["**/restate-effect/src/**"], "rules": { diff --git a/.oxlintrc.json.genie.ts b/.oxlintrc.json.genie.ts index 4873f0a4c..e35b0df60 100644 --- a/.oxlintrc.json.genie.ts +++ b/.oxlintrc.json.genie.ts @@ -4,6 +4,7 @@ import { baseOxlintOverrides, baseOxlintPlugins, baseOxlintRules, + otelOxlintRules, } from './genie/oxlint-base.ts' import { oxlintConfig, type OxlintConfigArgs } from './packages/@overeng/genie/src/runtime/mod.ts' @@ -27,6 +28,32 @@ export default oxlintConfig({ files: ['**/genie/src/runtime/**/*.test.ts'], rules: { 'overeng/no-external-imports': 'off' }, }, + // effect-utils: production code must use schema-backed OTEL contracts instead + // of raw Effect/Stream span primitives. Keep boundary/runtime/test exceptions + // narrow and explicit so repo-wide adoption remains mechanically checkable. + { + files: ['packages/@overeng/*/src/**/*.ts', 'packages/@overeng/*/src/**/*.tsx'], + rules: otelOxlintRules({ rawOtel: 'error' }), + }, + { + files: [ + 'packages/@overeng/otel-contract/src/**', + 'packages/@overeng/utils-dev/src/otelite/**', + 'packages/@overeng/*/src/**/*.test.ts', + 'packages/@overeng/*/src/**/*.test.tsx', + 'packages/@overeng/*/src/**/*.spec.ts', + 'packages/@overeng/*/src/**/*.spec.tsx', + 'packages/@overeng/*/src/**/*.unit.test.ts', + 'packages/@overeng/*/src/**/*.unit.test.tsx', + 'packages/@overeng/*/src/**/*.integration.test.ts', + 'packages/@overeng/*/src/**/*.integration.test.tsx', + 'packages/@overeng/*/src/**/*.e2e.test.ts', + 'packages/@overeng/*/src/**/*.e2e.test.tsx', + 'packages/@overeng/*/src/**/*.gen.ts', + 'packages/@overeng/*/src/**/*.gen.tsx', + ], + rules: otelOxlintRules({ rawOtel: 'off' }), + }, // restate-effect: ban raw nondeterminism in SOURCE handler code (R20, decision // 0004). The journaled Clock/Random + explicit durable combinators are the // primary guarantee; this lint is an advisory backstop. Scoped to `src/` only — diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3bd667b..759180e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,16 @@ All notable changes to this project will be documented in this file. ### Changed +- **@overeng/utils-dev/otelite**: Resolve the `otelite` binary from `OTELITE_BIN` before falling back to `PATH`, and document the plain-shell Nix workflow for focused wrapper tests. + +- **@overeng/otel-contract**: Add branded/refined OTEL name schemas (`OtelAttributeKey`, `OtelSpanName`, `OtelMetricName`, `OtelServiceName`), validate contract names/keys at definition time, add an Effect `Metric` runtime bridge for schema-first metric contracts, and extend the raw-OTEL lint rule to ban raw Effect `Metric.*` APIs outside approved contract/test boundaries. + +- **OTEL devenv module**: Stop requiring the retired legacy `otel` CLI in `OTEL_MODE=system`; dashboard refresh is now best-effort when a compatible legacy CLI is present, while shell setup still succeeds with the shared system stack. + - **@overeng/otelite-effect → @overeng/utils-dev/otelite**: Folded the standalone `@overeng/otelite-effect` wrapper package into `@overeng/utils-dev` as a new `./otelite` subpath export and deleted the standalone package. The wrapper is a dev/test util with no non-test consumer, and `utils-dev` already declares every dependency it needs (`@effect/platform`, `@effect/platform-node`, `@effect/opentelemetry`, `@effect/vitest`) and already uses subpath exports — co-locating it removes a `utils-dev ⇄ otelite-effect` cycle. The source moved to `packages/@overeng/utils-dev/src/otelite/` and the tests (incl. the D1 wire-level e2e) to `src/otelite/*.test.ts`. The public API is unchanged; consumers now import from `@overeng/utils-dev/otelite`. The `Otelite` service tag and the `Otelite*` error tags are renamespaced to `@overeng/utils-dev/otelite/*`. The tests still run the real nix-built `otelite` binary on `PATH` (provided by the dev shell). See `context/otelite/decisions/0015`. +- **CI / Nix packages**: Refresh the stale `genie`, `megarepo`, `notion-md`, and `tui-stories` pnpm fixed-output hashes after the schema-first OTEL contract change updated the workspace dependency closure. + - **CI / Nix packages**: Refresh the stale `workflow-report` pnpm fixed-output hash so the Storybook preview reporting step can build `#workflow-report` again after the branch rebase updated the workspace dependency closure. - **@overeng/restate-effect**: Made `Restate.run`'s type HONEST. A durable `ctx.run` step carries NO catchable typed failure: the inner effect runs via `Runtime.runPromise` inside `ctx.run`, so a typed `Effect.fail` only REJECTS the step (Restate retries; a give-up maps to a `RestateError` DEFECT) and never reaches the outer failure channel — the old `run(…): Effect` advertised a typed `E` that `catchTag`/`catchAll` would typecheck against but that could never fire. `run` is now `run(name, effect: Effect, options?): Effect`, and `runExit` is `runExit(…): Effect, never, …>` — the honest OBSERVATION form, whose failure channel is `never` (an observed failure is a defect/interrupt `Cause`, not a phantom typed `E`). Domain errors now belong in the HANDLER body (classify the step's result there) or are encoded as VALUES inside the step; to force a durable retry, DIE inside the step. A passed typed-`E` inner effect is now a COMPILE error (negative-type assertion in `capability-inference.types.ts`). Callers reconciled: the saga integration test's failing `pay` step `Effect.die`s (was `Effect.fail`), and `examples/12-self-reschedule.ts`'s `pollComposedSource` returns a tagged VALUE with `E = never` (classified in the cycle body, unchanged). `examples/14-http-error-classification.ts` already used the die-the-step / classify-in-body strategies; only its prose was corrected. VRS: decision 0003 (#4 — corrects the earlier "keep the inner `E` flowing through `run`"), 03-effect-runtime / 04-error-boundary specs, the guide handbook, and a DEFERRED typed-failure-transport `run` note (an encoded `fail(E)` journaled via an error schema). No dependency changes. @@ -22,6 +30,10 @@ All notable changes to this project will be documented in this file. ### Added +- **@overeng/otel-contract**: Add the schema-first OTEL operation and metric-contract DSL (`OtelOperation`, `OtelMetric`, `OtelSpan.withStream`, attribute builders, compiled metadata, `encodeSync`, and checked dynamic span-map annotations) and migrate product instrumentation across the repo off raw `Effect.withSpan` / `Stream.withSpan` / `Effect.annotateCurrentSpan` and normal `unsafe*` contract calls. The contract remains runtime-light: it owns schema-backed names, labels, attributes, cardinality metadata, and encoders, while package-local code keeps exporter/provider setup, service identity, Restate replay gates, and runtime-specific bridges. `@overeng/oxc-config` now ships `overeng/no-raw-otel-primitives` with generated rollout config, `@overeng/utils-dev/otelite` gains reusable metric/log expectation helpers, and `restate-effect` adopts the same idiom for internal spans while preserving hook-owned Restate spans and replay-aware metrics. + +- **@overeng/utils/node/otel-attrs**: Add schema-first OTEL attribute and span contracts (`OtelAttr`, `OtelAttrs`, `OtelSpan`) plus otelite expectation helpers that derive span assertions from the same compiled attribute encoders used by runtime instrumentation. Ambiguous encodings fail closed unless explicitly annotated, redacted values only support redacted/drop policies, and span definitions require the dedicated `OtelAttr.spanLabel()` contract. + - **@overeng/utils-dev/otelite**: Add an Effect-native otelite test harness and trace assertion DSL. `OteliteTestHarness` wraps scoped `Otelite.capture` lifetimes, in-process OTLP exporter wiring, serialized env-backed capture setup, flush-before-inspect helpers, and ergonomic `captureTest` / `captureInProcessTrace` / `captureEnvTrace` helpers. `expectTrace` adds runner-agnostic span assertions for service/name/attribute matching, `span.label` enforcement, and same-trace topology checks. - **@overeng/utils-dev/otelite**: Add a vitest ↔ otelite capture bridge that wires an in-process `Otelite.capture` receiver to a vitest test's OTLP trace exporter, so spans emitted IN-PROCESS through the normal `@effect/opentelemetry` `OtlpTracer` layer land in a capture the test can assert over. `makeOteliteCaptureLayer(options?)` is a scoped `Layer` that boots ONE receiver, exposes its `CaptureHandle` via the new `OteliteCapture` `Context.Tag`, AND installs the trace exporter pointed at `${handle.endpoints.http}/v1/traces`; used with `@effect/vitest`'s `layer(...)` it gives a PER-FILE lifecycle (one receiver per test file, shared across that file's tests; tests disambiguate by a unique `service.name` / span name) — the cheap default per decision 0015, with per-test available by giving each `layer(...)` its own instance. A test does `const cap = yield* OteliteCapture; …; yield* cap.inspect({ signal: 'traces', name })`. `flushCaptureSpans()` force-flushes the exporter (the emitter's job) before inspecting. Silent-failure guard: a misrouted exporter (the `/v1/traces` suffix bug, see Fixed) lands nothing, so the demonstrator's non-zero `inspect`/`span_count` assertions FAIL the test rather than pass vacuously. Real-binary tests emit a span in-process through the REAL exporter and assert it round-trips, plus a regression that a bare un-suffixed URL captures nothing (#769, #772). - **@overeng/notion-effect-client**: Add a real-consumer span-assertion demonstrator (D3, decision 0015) co-located in this client's own test suite (`src/test/otelite-span-shape.test.ts`). It drives the REAL instrumented query path (`NotionDatabases.query` → `executeRequest` → `Effect.withSpan('NotionHttp.POST')`) against a STUB upstream — a `HttpClient.make(...)` answering the one `POST /data_sources/{id}/query` endpoint with a canned empty paginated list + `x-ratelimit-remaining`/`x-request-id` headers — under the `@overeng/utils-dev/otelite` capture bridge, with NO secrets and NO network. It asserts the emitted span shape: exactly one `NotionHttp.POST` span carrying the templated `notion.http.route` = `/data_sources/{data_source_id}/query` + `notion.http.method`/`operation`/`status_code` (200) + `notion.rate_limit.remaining` (42); exactly one auto `http.client POST` child from `@effect/platform` whose `url.path` proves the stub served the request; a non-zero `span_count` (silent-export guard); and a public-repo leak guard that NO captured span attribute carries an `authorization` header or the token value (`@effect/platform` records only a header subset and excludes Authorization). The churn-coupled `notion.http.*` assertions sit next to the instrumentation that churns; the bridge stays a lean shared helper. The shadowing gotcha (the bridge re-exports the exporter's `FetchHttpClient` as `HttpClient.HttpClient`) is resolved by providing the stub to the effect-under-test directly (`Effect.provide`, innermost-wins) so the consumer sees the stub. Runs the real nix-built `otelite` binary on `PATH` (#769, #772). diff --git a/context/otel.md b/context/otel.md index effebd6f4..e17ea570a 100644 --- a/context/otel.md +++ b/context/otel.md @@ -47,7 +47,7 @@ mode = "auto" (default) └── not set? → "local": starts per-project Collector/Tempo/Grafana ``` -When in system mode, this module requires `OTEL_STATE_DIR`, `OTEL_EXPORTER_OTLP_ENDPOINT`, and the `otel` CLI. Shell entry fails immediately if any are missing. Dashboards are synced by invoking `otel dash sync` on shell entry, targeting `$OTEL_STATE_DIR/dashboards`. +When in system mode, this module requires `OTEL_STATE_DIR`, `OTEL_EXPORTER_OTLP_ENDPOINT`, and `OTEL_GRAFANA_URL`. Shell entry fails immediately if those required environment variables are missing. Dashboard sync is best-effort: when a compatible legacy `otel` CLI is available, shell entry invokes `otel dash sync` against `$OTEL_STATE_DIR/dashboards`; otherwise it warns and continues. ## Environment Variables @@ -190,7 +190,7 @@ jsonnet -J path/to/grafonnet dt-tasks.jsonnet | jq . ### Project Dashboards (`.otel/dashboards.json`) -Projects define their own dashboards in `.otel/dashboards.json`. In system mode, dashboard syncing is delegated to `otel dash sync` on shell entry. `extraDashboards` is local-mode only and is rejected in system mode. +Projects define their own dashboards in `.otel/dashboards.json`. In system mode, dashboard syncing is best-effort on shell entry when a compatible legacy `otel` CLI is available; missing dashboard sync tooling must not block the shell because the standalone `otel` binary is retired. `extraDashboards` is local-mode only and is rejected in system mode. ## Data Storage diff --git a/devenv.nix b/devenv.nix index 2bb779c72..820d349e2 100644 --- a/devenv.nix +++ b/devenv.nix @@ -125,6 +125,7 @@ let "packages/@overeng/notion-effect-schema" "packages/@overeng/notion-md" "packages/@overeng/notion-react" + "packages/@overeng/otel-contract" "packages/@overeng/oxc-config" "packages/@overeng/pty-effect" "packages/@overeng/react-inspector" diff --git a/genie/oxlint-base.ts b/genie/oxlint-base.ts index b5e9ea3e9..2165cbb22 100644 --- a/genie/oxlint-base.ts +++ b/genie/oxlint-base.ts @@ -9,6 +9,18 @@ import type { OxlintOverride, } from '../packages/@overeng/genie/src/runtime/mod.ts' +type OxlintRuleSeverity = 'off' | 'warn' | 'error' + +type OtelOxlintRulesArgs = { + /** Severity for raw Effect/Stream OTEL span primitives. */ + readonly rawOtel: OxlintRuleSeverity + /** + * Reserved for the second enforcement tier, after `OtelOperation` fully + * replaces product-code `OtelSpan.unsafe*` usage. + */ + readonly unsafeContract?: OxlintRuleSeverity +} + /** Standard ignore patterns for oxlint across all repos */ export const baseOxlintIgnorePatterns = [ '**/node_modules/**', @@ -48,6 +60,17 @@ export const baseOxlintCategories = { restriction: 'off', } as const satisfies OxlintConfigArgs['categories'] +/** + * Shared OTEL lint policy helper. + * + * Repos should use this instead of spelling raw rule names inline so the + * cross-megarepo rollout can move from warn to error without policy drift. + */ +export const otelOxlintRules = ({ rawOtel }: OtelOxlintRulesArgs): OxlintOverride['rules'] => + ({ + 'overeng/no-raw-otel-primitives': rawOtel, + }) satisfies OxlintOverride['rules'] + /** Standard rules shared across all repos */ export const baseOxlintRules = { // Disallow dynamic import() and require() - helps with static analysis and bundling @@ -80,6 +103,9 @@ export const baseOxlintRules = { // Enforce proper type imports 'typescript/consistent-type-imports': 'warn', + // OTEL raw primitive enforcement is enabled through generated repo overrides. + 'overeng/no-raw-otel-primitives': 'off', + // Don't enforce type vs interface 'typescript/consistent-type-definitions': 'off', @@ -158,6 +184,7 @@ export const generatedFilesRules = { 'overeng/exports-first': 'off', 'overeng/jsdoc-require-exports': 'off', 'overeng/named-args': 'off', + 'overeng/no-raw-otel-primitives': 'off', 'unicorn/consistent-function-scoping': 'off', } as const satisfies OxlintOverride['rules'] @@ -212,6 +239,7 @@ export const baseOxlintOverrides = [ files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'], rules: { 'overeng/named-args': 'off', + 'overeng/no-raw-otel-primitives': 'off', 'unicorn/no-array-sort': 'off', 'unicorn/consistent-function-scoping': 'off', 'require-yield': 'off', diff --git a/genie/packages.ts b/genie/packages.ts index 36ee71010..dc1d92947 100644 --- a/genie/packages.ts +++ b/genie/packages.ts @@ -9,8 +9,12 @@ * All internal @overeng/* package short names. */ export const internalPackages = [ + 'agent-session-ingest', 'content-address', + 'effect-ai-claude-cli', 'effect-path', + 'effect-react', + 'effect-rpc-tanstack', 'effect-schema-form', 'effect-schema-form-aria', 'genie', @@ -22,14 +26,19 @@ export const internalPackages = [ 'notion-datasource-sync', 'notion-effect-client', 'notion-effect-schema', + 'notion-md', 'notion-react', + 'otel-contract', + 'oxc-config', 'pty-effect', + 'react-inspector', 'restate-effect', 'tui-core', 'tui-react', 'tui-stories', 'utils', 'utils-dev', + 'workflow-report', ] as const /** Short name of an internal @overeng/* package. */ diff --git a/nix/devenv-modules/otel.nix b/nix/devenv-modules/otel.nix index cb2951ff4..ad265b934 100644 --- a/nix/devenv-modules/otel.nix +++ b/nix/devenv-modules/otel.nix @@ -31,7 +31,7 @@ # # Shell helpers: # - otel-span: emit OTLP trace spans from shell scripts (see otel-span --help) -# - otel health: diagnose the OTEL stack health (see otel health --help) +# - otel-trace: print the current Grafana/Tempo trace link # { # Fixed base port (null = derive from $DEVENV_ROOT hash) @@ -42,7 +42,8 @@ # Mode: "auto" detects system stack, "local" always uses local, "system" always uses system mode ? "auto", # Pre-compiled project-specific dashboards to provision alongside built-in ones. - # Only used for local Grafana provisioning; OTEL_MODE=system requires explicit otel-cli sources. + # Only used for local Grafana provisioning. OTEL_MODE=system uses the shared + # stack and refreshes dashboards only when a compatible legacy otel CLI exists. # Each entry: { name = "my-project"; path = ; } # Use lib.buildOtelDashboards to compile Jsonnet sources into the expected format. extraDashboards ? [ ], @@ -382,16 +383,14 @@ let echo "[otel] ERROR: OTEL_MODE=system requires OTEL_GRAFANA_URL" >&2 return 1 fi - if ! command -v otel >/dev/null 2>&1; then - echo "[otel] ERROR: OTEL_MODE=system requires otel CLI for dashboard sync" >&2 - return 1 - fi if [ "${toString (builtins.length extraDashboards)}" -gt 0 ]; then echo "[otel] ERROR: extraDashboards is not supported in OTEL_MODE=system" >&2 return 1 fi - _otel_project_name="$(basename "''${DEVENV_ROOT:-devenv}")" - if otel dash sync --help >/dev/null 2>&1; then + _otel_project_name="$(${pkgs.coreutils}/bin/basename "''${DEVENV_ROOT:-devenv}")" + if ! command -v otel >/dev/null 2>&1; then + echo "[otel] WARN: legacy otel CLI unavailable; skipping system dashboard refresh" >&2 + elif otel dash sync --help >/dev/null 2>&1; then if ! otel dash sync \ --source "${allDashboards}" \ --target "$OTEL_STATE_DIR/dashboards" >/dev/null 2>&1; then @@ -791,7 +790,24 @@ in } _check "Shell state resolution (system requires Grafana URL)" _test_shell_state_system_requires_grafana - # Test 5: shell:entry emission uses explicit shell IDs and ignores ambient parents + # Test 5: system shell state does not require the retired legacy otel CLI. + _test_shell_state_system_without_legacy_otel_cli() { + ( + export OTEL_MODE="system" + export OTEL_STATE_DIR="$_tmp/system-state" + export OTEL_EXPORTER_OTLP_ENDPOINT="http://collector.example:4318" + export OTEL_GRAFANA_URL="http://grafana.example" + unset OTEL_SPAN_SPOOL_DIR + export PATH="/nonexistent" + resolve_otel_shell_state + [ "$OTEL_MODE" = "system" ] || return 1 + [ "$OTEL_GRAFANA_URL" = "http://grafana.example" ] || return 1 + [ -z "''${OTEL_SPAN_SPOOL_DIR:-}" ] || return 1 + ) + } + _check "Shell state resolution (system without legacy otel CLI)" _test_shell_state_system_without_legacy_otel_cli + + # Test 6: shell:entry emission uses explicit shell IDs and ignores ambient parents _test_shell_entry_root_span() { local spool="$_tmp/shell-entry-root" mkdir -p "$spool" @@ -824,7 +840,7 @@ in } _check "shell:entry root span emission" _test_shell_entry_root_span - # Test 6: shell:entry emission must not depend on PATH already containing + # Test 7: shell:entry emission must not depend on PATH already containing # otel-span because enterShell can run before package PATH setup settles. _test_shell_entry_root_span_without_path() { local spool="$_tmp/shell-entry-no-path" @@ -858,7 +874,7 @@ in } _check "shell:entry root span emission without PATH" _test_shell_entry_root_span_without_path - # Test 7: reload-trigger detection uses pinned binaries instead of + # Test 8: reload-trigger detection uses pinned binaries instead of # ambient PATH, so the shell-entry task works before GNU tools are added. _test_shell_entry_state_without_path() { local workdir="$_tmp/shell-entry-state-no-path" @@ -877,7 +893,7 @@ in } _check "shell-entry state detection without PATH" _test_shell_entry_state_without_path - # Test 8: reload-trigger detection tolerates input paths that disappear + # Test 9: reload-trigger detection tolerates input paths that disappear # between eval and shell startup instead of failing the shell-entry task. _test_shell_entry_state_missing_paths() { local workdir="$_tmp/shell-entry-state-missing-paths" @@ -896,7 +912,7 @@ in } _check "shell-entry state detection with missing paths" _test_shell_entry_state_missing_paths - # Test 9: TRACEPARENT propagation + # Test 10: TRACEPARENT propagation _test_traceparent() { local spool="$_tmp/tp-test" mkdir -p "$spool" diff --git a/nix/oxc-config-plugin.nix b/nix/oxc-config-plugin.nix index 21a0a8785..1c16a4b58 100644 --- a/nix/oxc-config-plugin.nix +++ b/nix/oxc-config-plugin.nix @@ -28,7 +28,7 @@ let pnpm = pinnedPnpm; }; packageDir = "packages/@overeng/oxc-config"; - pnpmDepsHash = "sha256-N0cQostBXjVhSaWN5zOJSiTBNkoQXBKoyMmcYjRr5Ps="; + pnpmDepsHash = "sha256-0MeOm3vZjJiGpmVAyt6fOavjhYfehVswkXvN6DGLsjQ="; srcPath = if builtins.isAttrs src && builtins.hasAttr "outPath" src then diff --git a/package.json b/package.json index c28dcffe8..5329ed214 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "packages/@overeng/notion-effect-schema", "packages/@overeng/notion-md", "packages/@overeng/notion-react", + "packages/@overeng/otel-contract", "packages/@overeng/oxc-config", "packages/@overeng/pty-effect", "packages/@overeng/react-inspector", diff --git a/package.json.genie.ts b/package.json.genie.ts index d289acaad..5d6d6be80 100644 --- a/package.json.genie.ts +++ b/package.json.genie.ts @@ -21,6 +21,7 @@ import notionEffectClientPkg from './packages/@overeng/notion-effect-client/pack import notionEffectSchemaPkg from './packages/@overeng/notion-effect-schema/package.json.genie.ts' import notionMdPkg from './packages/@overeng/notion-md/package.json.genie.ts' import notionReactPkg from './packages/@overeng/notion-react/package.json.genie.ts' +import otelContractPkg from './packages/@overeng/otel-contract/package.json.genie.ts' import oxcConfigPkg from './packages/@overeng/oxc-config/package.json.genie.ts' import ptyEffectPkg from './packages/@overeng/pty-effect/package.json.genie.ts' import reactInspectorPkg from './packages/@overeng/react-inspector/package.json.genie.ts' @@ -55,6 +56,7 @@ export const rootWorkspacePackages = [ notionEffectSchemaPkg, notionMdPkg, notionReactPkg, + otelContractPkg, oxcConfigPkg, ptyEffectPkg, opentuiPkg, diff --git a/packages/@overeng/effect-react/package.json b/packages/@overeng/effect-react/package.json index a6f00154d..105a158f3 100644 --- a/packages/@overeng/effect-react/package.json +++ b/packages/@overeng/effect-react/package.json @@ -18,6 +18,9 @@ "storybook": "storybook dev -p 6009", "storybook:build": "storybook build" }, + "dependencies": { + "@overeng/otel-contract": "workspace:^" + }, "devDependencies": { "@overeng/utils": "workspace:^", "@storybook/react": "10.2.3", @@ -45,6 +48,7 @@ "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ "packages/@overeng/effect-react", + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/effect-react/package.json.genie.ts b/packages/@overeng/effect-react/package.json.genie.ts index c1d5276a9..4404be500 100644 --- a/packages/@overeng/effect-react/package.json.genie.ts +++ b/packages/@overeng/effect-react/package.json.genie.ts @@ -5,11 +5,15 @@ import { privatePackageDefaults, type PackageJsonData, } from '../../../genie/internal.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import utilsPkg from '../utils/package.json.genie.ts' const peerDepNames = ['effect', 'react', 'react-aria-components', 'react-dom'] as const const workspaceDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/effect-react' }), + dependencies: { + workspace: [otelContractPkg], + }, devDependencies: { workspace: [utilsPkg], external: { diff --git a/packages/@overeng/effect-react/src/context.tsx b/packages/@overeng/effect-react/src/context.tsx index 021a8fc26..da08e68fc 100644 --- a/packages/@overeng/effect-react/src/context.tsx +++ b/packages/@overeng/effect-react/src/context.tsx @@ -1,7 +1,19 @@ /** @jsxImportSource react */ -import { Cause, Effect, Exit, Fiber, type Layer, ManagedRuntime, Runtime, type Scope } from 'effect' +import { + Cause, + Effect, + Exit, + Fiber, + type Layer, + ManagedRuntime, + Runtime, + Schema, + type Scope, +} from 'effect' import React from 'react' +import { OtelOperation } from '@overeng/otel-contract' + // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- @@ -35,6 +47,13 @@ type EffectContextValue = { const EffectContext = React.createContext(null) +const EffectRunnerOperation = OtelOperation.define({ + name: 'ui.effect', + schema: Schema.Struct({}), + label: () => 'effect', + root: true, +}) + // ----------------------------------------------------------------------------- // Provider // ----------------------------------------------------------------------------- @@ -165,7 +184,7 @@ export const useRuntime = (): Runtime.Runtime => { * Effect.gen(function* () { * yield* Effect.log('Button clicked') * yield* doSomething() - * }).pipe(Effect.withSpan('button.click')) + * }).pipe(ButtonClickOperation.with({})) * ) * } * @@ -188,7 +207,7 @@ export const useEffectRunner = (): (( (effect: ProviderEffect): CancelFn => { const fiber = effect.pipe( Effect.tapErrorCause((cause) => Effect.sync(() => onError(cause))), - Effect.withSpan('ui.effect', { root: true }), + EffectRunnerOperation.with({}), Effect.scoped, Runtime.runFork(typedRuntime), ) diff --git a/packages/@overeng/effect-react/src/effect-button.tsx b/packages/@overeng/effect-react/src/effect-button.tsx index 16c8a3504..7e572bf3a 100644 --- a/packages/@overeng/effect-react/src/effect-button.tsx +++ b/packages/@overeng/effect-react/src/effect-button.tsx @@ -1,8 +1,16 @@ -import { Effect, Exit, type Runtime, type Scope, Stream } from 'effect' +import { Effect, Exit, Schema, type Runtime, type Scope, Stream } from 'effect' import React from 'react' +import { OtelOperation } from '@overeng/otel-contract' + import { initialProgress, type Progress, ProgressReporter } from './progress-reporter.ts' +const EffectButtonOperation = OtelOperation.define({ + name: 'ui.effect-button', + schema: Schema.Struct({}), + label: () => 'effect-button', +}) + /** * UI state for a running Effect button controller. * @typeParam TA - Success value type @@ -156,7 +164,7 @@ export const useEffectButton = ({ /** Re-raise so the effect runner can surface the error. */ return yield* Effect.failCause(exit.cause) - }).pipe(Effect.withSpan('ui.effect-button')), + }).pipe(EffectButtonOperation.with({})), ) setState({ _tag: 'running', startedAt, cancel, progress: initialProgress }) diff --git a/packages/@overeng/effect-react/tsconfig.json b/packages/@overeng/effect-react/tsconfig.json index e01c88b10..b505b7d77 100644 --- a/packages/@overeng/effect-react/tsconfig.json +++ b/packages/@overeng/effect-react/tsconfig.json @@ -46,6 +46,9 @@ }, "include": ["src/**/*", "test/**/*"], "references": [ + { + "path": "../otel-contract" + }, { "path": "../utils" } diff --git a/packages/@overeng/effect-react/tsconfig.json.genie.ts b/packages/@overeng/effect-react/tsconfig.json.genie.ts index e5f31afad..28c00a3c2 100644 --- a/packages/@overeng/effect-react/tsconfig.json.genie.ts +++ b/packages/@overeng/effect-react/tsconfig.json.genie.ts @@ -15,5 +15,5 @@ export default tsconfigJson({ lib: [...domLib], }, include: ['src/**/*', 'test/**/*'], - references: [{ path: '../utils' }], + references: [{ path: '../otel-contract' }, { path: '../utils' }], } satisfies TSConfigArgs) diff --git a/packages/@overeng/effect-rpc-tanstack/examples/basic/package.json b/packages/@overeng/effect-rpc-tanstack/examples/basic/package.json index 63e656573..6a45f6bf1 100644 --- a/packages/@overeng/effect-rpc-tanstack/examples/basic/package.json +++ b/packages/@overeng/effect-rpc-tanstack/examples/basic/package.json @@ -34,6 +34,7 @@ "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ "packages/@overeng/effect-rpc-tanstack/examples/basic", + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/effect-rpc-tanstack/package.json b/packages/@overeng/effect-rpc-tanstack/package.json index 2f9d9aa07..3241296fa 100644 --- a/packages/@overeng/effect-rpc-tanstack/package.json +++ b/packages/@overeng/effect-rpc-tanstack/package.json @@ -51,6 +51,7 @@ "workspaceClosureDirs": [ "packages/@overeng/effect-rpc-tanstack", "packages/@overeng/effect-rpc-tanstack/examples/basic", + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/effect-schema-form-aria/package.json b/packages/@overeng/effect-schema-form-aria/package.json index 8821933f5..253d634d7 100644 --- a/packages/@overeng/effect-schema-form-aria/package.json +++ b/packages/@overeng/effect-schema-form-aria/package.json @@ -48,6 +48,7 @@ "workspaceClosureDirs": [ "packages/@overeng/effect-schema-form", "packages/@overeng/effect-schema-form-aria", + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/genie/nix/build.nix b/packages/@overeng/genie/nix/build.nix index 2370fab9a..e3f25c565 100644 --- a/packages/@overeng/genie/nix/build.nix +++ b/packages/@overeng/genie/nix/build.nix @@ -25,7 +25,7 @@ let # Managed by the repo FOD refresh workflow — do not edit manually. depsBuilds = { "." = { - hash = "sha256-Obb8EC8c1+b6YvcIGhSGfjtgAUVk8POo/rOjNC/XLPU="; + hash = "sha256-yV0ONh4haXUHi9isWdVnsuKfEXjGO8ESqsDrKALbVuU="; }; }; nativeNodePackages = [ opentuiCoreNative ]; diff --git a/packages/@overeng/genie/package.json b/packages/@overeng/genie/package.json index 75212670b..544d4be21 100644 --- a/packages/@overeng/genie/package.json +++ b/packages/@overeng/genie/package.json @@ -34,6 +34,7 @@ "@effect/workflow": "0.18.0", "@opentui/core": "0.1.88", "@opentui/react": "0.1.88", + "@overeng/otel-contract": "workspace:^", "@playwright/test": "1.59.1", "@storybook/react": "10.2.3", "@types/react": "19.2.7", @@ -99,6 +100,7 @@ "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ "packages/@overeng/genie", + "packages/@overeng/otel-contract", "packages/@overeng/tui-core", "packages/@overeng/tui-react", "packages/@overeng/utils", diff --git a/packages/@overeng/genie/package.json.genie.ts b/packages/@overeng/genie/package.json.genie.ts index dc99086ea..e3b2ca669 100644 --- a/packages/@overeng/genie/package.json.genie.ts +++ b/packages/@overeng/genie/package.json.genie.ts @@ -4,6 +4,7 @@ import { packageJson, privatePackageDefaults, } from '../../../genie/internal.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import tuiCorePkg from '../tui-core/package.json.genie.ts' import tuiReactPkg from '../tui-react/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' @@ -11,6 +12,9 @@ import utilsPkg from '../utils/package.json.genie.ts' const supportDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/genie' }), + dependencies: { + workspace: [otelContractPkg], + }, devDependencies: { workspace: [tuiCorePkg, tuiReactPkg, utilsDevPkg, utilsPkg], external: { diff --git a/packages/@overeng/genie/src/build/mod.tsx b/packages/@overeng/genie/src/build/mod.tsx index 63c80a551..8ad8594cd 100644 --- a/packages/@overeng/genie/src/build/mod.tsx +++ b/packages/@overeng/genie/src/build/mod.tsx @@ -20,6 +20,7 @@ import { findGenieFiles } from '../core/discovery.ts' import { GenieGenerationFailedError } from '../core/errors.ts' import { type GenieEvent, GenieEventBus } from '../core/events.ts' import { generateFile } from '../core/generation.ts' +import { withCliModeSpan } from '../core/observability.ts' import { createInitialGenieState, type GenieSummary, type GenieMode } from '../core/schema.ts' import { GenieApp } from './app.ts' import { GenieView } from './view.tsx' @@ -286,10 +287,7 @@ export const genieCommand = Cli.Command.make( ), { view: }, ) - }).pipe( - Effect.provide(outputModeLayer(output)), - Effect.withSpan(`genie/${cliMode}`, { attributes: { 'cli.mode': cliMode } }), - ) + }).pipe(Effect.provide(outputModeLayer(output)), withCliModeSpan(cliMode)) return handler }, diff --git a/packages/@overeng/genie/src/core/core.ts b/packages/@overeng/genie/src/core/core.ts index 96de95470..c7dfa6bc9 100644 --- a/packages/@overeng/genie/src/core/core.ts +++ b/packages/@overeng/genie/src/core/core.ts @@ -21,6 +21,7 @@ import { type LoadedGenieFile, isTdzError, } from './generation.ts' +import * as Observability from './observability.ts' import type { GenieFileStatus, GenieSummary } from './schema.ts' import type { GenerateSuccess } from './types.ts' import { runGenieValidation } from './validation.ts' @@ -40,6 +41,7 @@ export const resolveOxfmtConfigPath = Effect.fn('resolveOxfmtConfigPath')(functi explicitPath: Option.Option cwd: string }) { + yield* Observability.annotatePath({ label: 'oxfmt', path: cwd }) if (Option.isSome(explicitPath) === true) return explicitPath const fs = yield* FileSystem.FileSystem for (const conventionPath of OXFMT_CONFIG_CONVENTION_PATHS) { @@ -95,6 +97,7 @@ export type GenieGenerateResult = { /** Discover genie files and assert no duplicate targets. */ const discoverAndValidate = Effect.fn('genie/discoverAndValidate')(function* (cwd: string) { + yield* Observability.annotatePath({ label: 'discover', path: cwd }) const genieFiles = yield* findGenieFiles(cwd) const targetCounts = new Map() @@ -139,6 +142,7 @@ const runValidationOrFail = Effect.fn('genie/runValidationOrFail')(function* ({ genieFiles?: ReadonlyArray preloadedFiles?: ReadonlyArray }) { + yield* Observability.annotatePath({ label: 'validate', path: cwd }) const validationResult = yield* runGenieValidation({ cwd, ...(genieFiles !== undefined ? { genieFiles } : {}), @@ -332,7 +336,14 @@ export const generateAll = ({ yield* emit({ _tag: 'Complete', summary }) return { summary, files: successes } - }).pipe(Effect.withSpan('genie/generateAll')) + }).pipe( + Observability.withCommandSpan({ + label: dryRun === true ? 'dry-run' : readOnly === true ? 'generate' : 'generate-writable', + cwd, + readOnly, + dryRun, + }), + ) /** Check that all generated files are up to date. */ export const checkAll = ({ @@ -353,6 +364,11 @@ export const checkAll = ({ 12, ), ) + yield* Observability.annotateCommand({ + label: 'check', + cwd, + concurrency: checkConcurrency, + }) const genieFiles = yield* discoverAndValidate(cwd) @@ -397,6 +413,12 @@ export const checkAll = ({ path: string result: FileCheckResult }) { + yield* Observability.annotateFile({ + label: Observability.relativePath({ cwd, filePath }), + cwd, + genieFilePath: filePath, + targetFilePath: filePath.replace('.genie.ts', ''), + }) yield* emit({ _tag: 'FileCompleted', path: filePath, @@ -546,4 +568,9 @@ export const checkAll = ({ failed: 0, } yield* emit({ _tag: 'Complete', summary }) - }).pipe(Effect.withSpan('genie/checkAll')) + }).pipe( + Observability.withCommandSpan({ + label: 'check', + cwd, + }), + ) diff --git a/packages/@overeng/genie/src/core/discovery.ts b/packages/@overeng/genie/src/core/discovery.ts index e4bfe4fb6..840d4d69a 100644 --- a/packages/@overeng/genie/src/core/discovery.ts +++ b/packages/@overeng/genie/src/core/discovery.ts @@ -5,6 +5,7 @@ import { type Error as PlatformError, FileSystem, Path } from '@effect/platform' import { Effect, Option } from 'effect' import { resolveImportMapSpecifierForImporterSync } from './import-map/mod.ts' +import * as Observability from './observability.ts' import type { StatResult } from './types.ts' let importMapResolverRegistered = false @@ -57,7 +58,8 @@ type BunPluginBuilder = { * with Bun internals (ResolveMessage instanceof checks fail). We skip plugin registration * entirely in compiled binaries - files using `#...` imports need to be run with `bun run`. */ -export const ensureImportMapResolver = Effect.sync(() => { +export const ensureImportMapResolver = Effect.gen(function* () { + yield* Observability.annotatePath({ label: 'import-map', path: process.cwd() }) if (importMapResolverRegistered === true) return importMapResolverRegistered = true @@ -86,7 +88,7 @@ export const ensureImportMapResolver = Effect.sync(() => { }) }, }) -}).pipe(Effect.withSpan('genie.registerImportMapResolver')) +}).pipe(Observability.withImportMapResolverSpan) /** Directories to skip when searching for .genie.ts files */ const shouldSkipDirectory = (name: string): boolean => { @@ -113,6 +115,7 @@ export const isGenieFile = (file: string): boolean => file.endsWith('.genie.ts') * avoiding double generation and racey writes/chmod. */ export const findGenieFiles = Effect.fn('discovery/findGenieFiles')(function* (dir: string) { + yield* Observability.annotatePath({ label: 'find-files', path: dir }) const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path const warnings: string[] = [] diff --git a/packages/@overeng/genie/src/core/generation.ts b/packages/@overeng/genie/src/core/generation.ts index 364bfa68a..422b7cf47 100644 --- a/packages/@overeng/genie/src/core/generation.ts +++ b/packages/@overeng/genie/src/core/generation.ts @@ -26,6 +26,7 @@ import { InvalidOxfmtConfigError, } from './errors.ts' import { resolveImportMapsInSource } from './import-map/mod.ts' +import * as Observability from './observability.ts' import type { GenerateSuccess, GenieContext } from './types.ts' /** Loaded genie module plus base context reused across check and validation phases. */ @@ -327,6 +328,10 @@ const loadOxfmtConfig = Effect.fn('loadOxfmtConfig')(function* ({ }: { configPath: Option.Option }) { + yield* Observability.annotatePath({ + label: Option.isSome(configPath) === true ? 'oxfmt-config' : 'oxfmt-default', + path: Option.isSome(configPath) === true ? configPath.value : process.cwd(), + }) if (Option.isNone(configPath) === true) { return Option.none() } @@ -398,6 +403,10 @@ const formatWithOxfmt = Effect.fn('formatWithOxfmt')(function* ({ content: string configPath: Option.Option }) { + yield* Observability.annotateOxfmt({ + targetFilePath, + hasConfig: Option.isSome(configPath), + }) const ext = path.extname(targetFilePath) if (oxfmtSupportedExtensions.has(ext) === false) { @@ -456,6 +465,7 @@ const findRepoRoot = Effect.fn('findRepoRoot')(function* ({ startDir: string cwd: string }) { + yield* Observability.annotatePath({ label: 'repo-root', path: startDir }) const cacheKey = `${cwd}::${startDir}` const cached = repoRootCache.get(cacheKey) if (cached !== undefined) { @@ -520,6 +530,12 @@ export const loadGenieFile = Effect.fn('loadGenieFile')(function* ({ genieFilePath: string cwd: string }) { + yield* Observability.annotateFile({ + label: Observability.relativePath({ cwd, filePath: genieFilePath }), + cwd, + genieFilePath, + targetFilePath: genieFilePath.replace('.genie.ts', ''), + }) yield* ensureImportMapResolver const importPathBase = @@ -580,6 +596,15 @@ export const getExpectedContent = Effect.fn('getExpectedContent')(function* ({ oxfmtConfigPath: Option.Option loadedGenieFile?: LoadedGenieFile }) { + yield* Observability.annotateFile({ + label: Observability.relativePath({ + cwd, + filePath: genieFilePath.replace('.genie.ts', ''), + }), + cwd, + genieFilePath, + targetFilePath: genieFilePath.replace('.genie.ts', ''), + }) const targetFilePath = genieFilePath.replace('.genie.ts', '') const sourceFile = path.basename(genieFilePath) const loaded = @@ -661,7 +686,10 @@ const atomicWriteFile = ({ return yield* error }), ), - Effect.withSpan('atomicWriteFile'), + Observability.withAtomicWriteSpan({ + targetFilePath, + ...(mode === undefined ? {} : { mode }), + }), ) const withTargetLock = Effect.fn('genie/withTargetLock')(function* ({ @@ -673,6 +701,7 @@ const withTargetLock = Effect.fn('genie/withTargetLock')(function* ({ targetFilePath: string effect: Effect.Effect }) { + yield* Observability.annotateTargetLock({ cwd, targetFilePath }) /** Use cwd-relative dir instead of shared /tmp to avoid EACCES when multiple CI jobs with different UIDs share the same tmpdir */ const lockDir = path.join(cwd, 'tmp', 'genie-locks') const lockLayer = FileSystemBacking.layer({ lockDir }) @@ -794,7 +823,13 @@ export const generateFile = ({ }), ) }), - Effect.withSpan('generateFile'), + Observability.withFileSpan({ + cwd, + genieFilePath, + targetFilePath: genieFilePath.replace('.genie.ts', ''), + readOnly, + dryRun, + }), ) /** Check if a generated file matches its expected content */ @@ -854,4 +889,11 @@ export const checkFileDetailed = ({ } return { targetFilePath, loadedGenieFile } - }).pipe(Effect.withSpan('checkFile')) + }).pipe( + Observability.withFileSpan({ + label: Observability.relativePath({ cwd, filePath: genieFilePath.replace('.genie.ts', '') }), + cwd, + genieFilePath, + targetFilePath: genieFilePath.replace('.genie.ts', ''), + }), + ) diff --git a/packages/@overeng/genie/src/core/import-map/mod.ts b/packages/@overeng/genie/src/core/import-map/mod.ts index b53b844a9..0a4dcabff 100644 --- a/packages/@overeng/genie/src/core/import-map/mod.ts +++ b/packages/@overeng/genie/src/core/import-map/mod.ts @@ -18,6 +18,8 @@ import { pathToFileURL } from 'node:url' import { FileSystem } from '@effect/platform' import { Effect, Option } from 'effect' +import * as Observability from '../observability.ts' + /** Parsed import map from package.json#imports */ export type ImportMap = Record @@ -59,6 +61,7 @@ const parseImportMapFromGenieSource = (sourceContent: string): ImportMap => { export const findNearestPackageJson = Effect.fn('findNearestPackageJson')(function* ( fromPath: string, ) { + yield* Observability.annotatePath({ label: 'package.json', path: fromPath }) const effectFs = yield* FileSystem.FileSystem let dir = path.dirname(fromPath) const root = path.parse(dir).root @@ -85,6 +88,7 @@ export const findNearestPackageJson = Effect.fn('findNearestPackageJson')(functi export const findPackageJsonWithImports = Effect.fn('findPackageJsonWithImports')(function* ( fromPath: string, ) { + yield* Observability.annotatePath({ label: 'imports', path: fromPath }) const effectFs = yield* FileSystem.FileSystem let dir = path.dirname(fromPath) const root = path.parse(dir).root @@ -183,6 +187,7 @@ export const findPackageJsonWithImportsSync = (fromPath: string): string | undef * genie source has imports but the generated file hasn't been updated yet. */ export const extractImportMap = Effect.fn('extractImportMap')(function* (packageJsonPath: string) { + yield* Observability.annotatePath({ label: 'import-map', path: packageJsonPath }) const effectFs = yield* FileSystem.FileSystem // First try the generated package.json @@ -556,6 +561,7 @@ export const resolveImportMapSpecifier = ({ export const resolveImportMapSpecifierForImporter = Effect.fn( 'genie.resolveImportMapSpecifierForImporter', )(function* ({ specifier, importerPath }: { specifier: string; importerPath: string }) { + yield* Observability.annotatePath({ label: specifier, path: importerPath }) if (isImportMapSpecifier(specifier) === false) { return Option.none() } @@ -656,6 +662,7 @@ export const resolveImportMapsInSource = Effect.fn('resolveImportMapsInSource')( sourcePath: string resolveRelativeImports?: boolean }) { + yield* Observability.annotatePath({ label: 'resolve-imports', path: sourcePath }) const packageJsonPathOption = yield* findPackageJsonWithImports(sourcePath) const packageJsonPath = Option.getOrUndefined(packageJsonPathOption) const importMap = packageJsonPath === undefined ? {} : yield* extractImportMap(packageJsonPath) diff --git a/packages/@overeng/genie/src/core/observability.ts b/packages/@overeng/genie/src/core/observability.ts new file mode 100644 index 000000000..2988b8c74 --- /dev/null +++ b/packages/@overeng/genie/src/core/observability.ts @@ -0,0 +1,358 @@ +import path from 'node:path' + +import { Effect, Schema } from 'effect' + +import { + OtelAttr, + OtelAttrs, + OtelOperation, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + +const basename = (filePath: string): string => + filePath.split(/[\\/]/).findLast((part) => part.length > 0) ?? filePath + +export const relativePath = ({ cwd, filePath }: { cwd: string; filePath: string }): string => { + const relative = path.relative(cwd, filePath).split(path.sep).join('/') + return relative.length > 0 ? relative : basename(filePath) +} + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchAll((error) => + typeof error === 'object' && + error !== null && + '_tag' in error && + error._tag === 'OtelAttrEncodeError' + ? Effect.die(error) + : Effect.fail(error as E), + ), + ) as Effect.Effect + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ): ((effect: Effect.Effect) => Effect.Effect) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + +const trustedAnnotate = ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, +): Effect.Effect => trustOtelContract(operation.annotate(attributes)) + +const commandAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + cwd: Schema.String.pipe(OtelAttr.key({ key: 'genie.cwd' })), + readOnly: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'genie.read_only' }))), + dryRun: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'genie.dry_run' }))), + concurrency: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'genie.concurrency' }))), + }), +) + +export const commandSpan = OtelOperation.define({ + name: 'genie/command', + attributes: commandAttrs, + label: ({ label }) => label, + root: true, +}) + +const fileAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + cwd: Schema.String.pipe(OtelAttr.key({ key: 'genie.cwd' })), + genieFilePath: Schema.String.pipe(OtelAttr.key({ key: 'genie.file.source_path' })), + targetFilePath: Schema.String.pipe(OtelAttr.key({ key: 'genie.file.target_path' })), + readOnly: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'genie.read_only' }))), + dryRun: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'genie.dry_run' }))), + }), +) + +export const fileSpan = OtelOperation.define({ + name: 'genie/file', + attributes: fileAttrs, + label: ({ label }) => label, +}) + +export const pathAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + path: Schema.String.pipe(OtelAttr.key({ key: 'genie.path' })), + }), +) + +const pathOperation = OtelOperation.define({ + name: 'genie/path', + attributes: pathAttrs, + label: ({ label }) => label, +}) + +export const oxfmtAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + targetFilePath: Schema.String.pipe(OtelAttr.key({ key: 'genie.file.target_path' })), + hasConfig: Schema.Boolean.pipe(OtelAttr.key({ key: 'genie.oxfmt.has_config' })), + }), +) + +const oxfmtOperation = OtelOperation.define({ + name: 'genie/oxfmt', + attributes: oxfmtAttrs, + label: ({ label }) => label, +}) + +const validationAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + cwd: Schema.String.pipe(OtelAttr.key({ key: 'genie.cwd' })), + requirePackageJsonValidate: Schema.Boolean.pipe( + OtelAttr.key({ key: 'genie.validation.require_package_json_validate' }), + ), + fileCount: Schema.optional( + Schema.Number.pipe(OtelAttr.key({ key: 'genie.validation.file_count' })), + ), + preloadedFileCount: Schema.optional( + Schema.Number.pipe(OtelAttr.key({ key: 'genie.validation.preloaded_file_count' })), + ), + }), +) + +export const validationSpan = OtelOperation.define({ + name: 'genie/runValidation', + attributes: validationAttrs, + label: ({ label }) => label, +}) + +const atomicWriteAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + targetFilePath: Schema.String.pipe(OtelAttr.key({ key: 'genie.file.target_path' })), + mode: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'genie.file.mode' }))), + }), +) + +export const atomicWriteSpan = OtelOperation.define({ + name: 'atomicWriteFile', + attributes: atomicWriteAttrs, + label: ({ label }) => label, +}) + +const importMapResolverAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + }), +) + +export const importMapResolverSpan = OtelOperation.define({ + name: 'genie.registerImportMapResolver', + attributes: importMapResolverAttrs, + label: ({ label }) => label, +}) + +export const targetLockAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + cwd: Schema.String.pipe(OtelAttr.key({ key: 'genie.cwd' })), + targetFilePath: Schema.String.pipe(OtelAttr.key({ key: 'genie.file.target_path' })), + }), +) + +const targetLockOperation = OtelOperation.define({ + name: 'genie/target-lock', + attributes: targetLockAttrs, + label: ({ label }) => label, +}) + +const cliModeAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + cliMode: Schema.String.pipe(OtelAttr.key({ key: 'cli.mode' })), + }), +) + +const cliModeOperation = (cliMode: string) => + OtelOperation.define({ + name: `genie/${cliMode}`, + attributes: cliModeAttrs, + label: ({ label }) => label, + }) + +export const withCliModeSpan = (cliMode: string) => + trustedWith(cliModeOperation(cliMode), { label: cliMode, cliMode }) + +export const withCommandSpan = ({ + label, + cwd, + readOnly, + dryRun, + concurrency, +}: { + label: string + cwd: string + readOnly?: boolean + dryRun?: boolean + concurrency?: number +}) => + trustedWith(commandSpan, { + label, + cwd, + ...(readOnly === undefined ? {} : { readOnly }), + ...(dryRun === undefined ? {} : { dryRun }), + ...(concurrency === undefined ? {} : { concurrency }), + }) + +export const withFileSpan = ({ + label, + cwd, + genieFilePath, + targetFilePath, + readOnly, + dryRun, +}: { + label?: string + cwd: string + genieFilePath: string + targetFilePath: string + readOnly?: boolean + dryRun?: boolean +}) => + trustedWith(fileSpan, { + label: label ?? relativePath({ cwd, filePath: targetFilePath }), + cwd, + genieFilePath, + targetFilePath, + ...(readOnly === undefined ? {} : { readOnly }), + ...(dryRun === undefined ? {} : { dryRun }), + }) + +export const withAtomicWriteSpan = ({ + targetFilePath, + mode, +}: { + targetFilePath: string + mode?: number +}) => + trustedWith(atomicWriteSpan, { + label: basename(targetFilePath), + targetFilePath, + ...(mode === undefined ? {} : { mode }), + }) + +export const withImportMapResolverSpan = trustedWith(importMapResolverSpan, { label: 'import-map' }) + +export const withValidationSpan = ({ + cwd, + requirePackageJsonValidate, + fileCount, + preloadedFileCount, +}: { + cwd: string + requirePackageJsonValidate: boolean + fileCount?: number + preloadedFileCount?: number +}) => + trustedWith(validationSpan, { + label: 'validate', + cwd, + requirePackageJsonValidate, + ...(fileCount === undefined ? {} : { fileCount }), + ...(preloadedFileCount === undefined ? {} : { preloadedFileCount }), + }) + +export const annotateCommand = ({ + label, + cwd, + readOnly, + dryRun, + concurrency, +}: { + label: string + cwd: string + readOnly?: boolean + dryRun?: boolean + concurrency?: number +}) => + trustedAnnotate(commandSpan, { + label, + cwd, + ...(readOnly === undefined ? {} : { readOnly }), + ...(dryRun === undefined ? {} : { dryRun }), + ...(concurrency === undefined ? {} : { concurrency }), + }) + +export const annotateFile = ({ + label, + cwd, + genieFilePath, + targetFilePath, + readOnly, + dryRun, +}: { + label?: string + cwd: string + genieFilePath: string + targetFilePath: string + readOnly?: boolean + dryRun?: boolean +}) => + trustedAnnotate(fileSpan, { + label: label ?? relativePath({ cwd, filePath: targetFilePath }), + cwd, + genieFilePath, + targetFilePath, + ...(readOnly === undefined ? {} : { readOnly }), + ...(dryRun === undefined ? {} : { dryRun }), + }) + +export const annotateValidation = ({ + cwd, + requirePackageJsonValidate, + fileCount, + preloadedFileCount, +}: { + cwd: string + requirePackageJsonValidate: boolean + fileCount?: number + preloadedFileCount?: number +}) => + trustedAnnotate(validationSpan, { + label: 'validate', + cwd, + requirePackageJsonValidate, + ...(fileCount === undefined ? {} : { fileCount }), + ...(preloadedFileCount === undefined ? {} : { preloadedFileCount }), + }) + +export const annotatePath = ({ label, path }: { label?: string; path: string }) => + trustedAnnotate(pathOperation, { label: label ?? basename(path), path }) + +export const annotateTargetLock = ({ + cwd, + targetFilePath, +}: { + cwd: string + targetFilePath: string +}) => + trustedAnnotate(targetLockOperation, { + label: relativePath({ cwd, filePath: targetFilePath }), + cwd, + targetFilePath, + }) + +export const annotateOxfmt = ({ + targetFilePath, + hasConfig, +}: { + targetFilePath: string + hasConfig: boolean +}) => + trustedAnnotate(oxfmtOperation, { + label: basename(targetFilePath), + targetFilePath, + hasConfig, + }) diff --git a/packages/@overeng/genie/src/core/package-json-context.ts b/packages/@overeng/genie/src/core/package-json-context.ts index 920f305b8..580a36ab2 100644 --- a/packages/@overeng/genie/src/core/package-json-context.ts +++ b/packages/@overeng/genie/src/core/package-json-context.ts @@ -2,6 +2,7 @@ import { FileSystem, Path } from '@effect/platform' import { Effect } from 'effect' import type { PackageInfo } from '../common/types.ts' +import * as Observability from './observability.ts' /** Workspace provider that discovers package.json paths in a monorepo */ export type WorkspaceProviderName = 'pnpm' | 'bun' | 'manual' @@ -20,6 +21,7 @@ const normalizePath = (input: string): string => input.replace(/\\/g, '/') export const buildPackageJsonValidationContext = Effect.fn( 'genie/buildPackageJsonValidationContext', )(function* ({ cwd, workspaceProvider }: { cwd: string; workspaceProvider: WorkspaceProvider }) { + yield* Observability.annotatePath({ label: workspaceProvider.name, path: cwd }) const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path const packageJsonPaths = yield* workspaceProvider.discoverPackageJsonPaths({ cwd }) diff --git a/packages/@overeng/genie/src/core/validation.ts b/packages/@overeng/genie/src/core/validation.ts index 862bfb8f2..62600cb19 100644 --- a/packages/@overeng/genie/src/core/validation.ts +++ b/packages/@overeng/genie/src/core/validation.ts @@ -7,6 +7,7 @@ import { findGenieFiles } from './discovery.ts' import { GenieValidationError } from './errors.ts' import type { GenieImportError } from './errors.ts' import { loadGenieFile, type LoadedGenieFile } from './generation.ts' +import * as Observability from './observability.ts' import { buildPackageJsonValidationContext } from './package-json-context.ts' import { resolveWorkspaceProvider } from './workspace.ts' @@ -27,6 +28,12 @@ export const runGenieValidation = ({ FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { + yield* Observability.annotateValidation({ + cwd, + requirePackageJsonValidate, + ...(genieFiles === undefined ? {} : { fileCount: genieFiles.length }), + ...(preloadedFiles === undefined ? {} : { preloadedFileCount: preloadedFiles.length }), + }) const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path const workspaceProvider = yield* resolveWorkspaceProvider({ cwd }) @@ -107,4 +114,11 @@ export const runGenieValidation = ({ } return issues - }).pipe(Effect.withSpan('genie/runValidation')) + }).pipe( + Observability.withValidationSpan({ + cwd, + requirePackageJsonValidate, + ...(genieFiles === undefined ? {} : { fileCount: genieFiles.length }), + ...(preloadedFiles === undefined ? {} : { preloadedFileCount: preloadedFiles.length }), + }), + ) diff --git a/packages/@overeng/genie/src/core/workspace.ts b/packages/@overeng/genie/src/core/workspace.ts index 7b74efb1c..b90b9af89 100644 --- a/packages/@overeng/genie/src/core/workspace.ts +++ b/packages/@overeng/genie/src/core/workspace.ts @@ -3,6 +3,7 @@ import { Effect } from 'effect' import { matchesAnyPattern } from '../runtime/package-json/validation.ts' import { GenieNotImplementedError } from './errors.ts' +import * as Observability from './observability.ts' import type { WorkspaceProvider, WorkspaceProviderName } from './package-json-context.ts' const DEFAULT_SKIP_DIRS = new Set([ @@ -26,6 +27,7 @@ const findWorkspaceRoot = Effect.fn('workspace/findWorkspaceRoot')(function* ({ }: { cwd: string }) { + yield* Observability.annotatePath({ label: 'workspace-root', path: cwd }) const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path @@ -51,6 +53,7 @@ const findPackageJsonDirs = Effect.fn('workspace/findPackageJsonDirs')(function* }: { root: string }) { + yield* Observability.annotatePath({ label: 'packages', path: root }) const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path const results: string[] = [] @@ -112,6 +115,7 @@ const parsePnpmWorkspacePackages = (content: string): string[] => { const discoverPnpmPackageJsonPaths = Effect.fn('workspace/discoverPnpmPackageJsonPaths')( function* ({ cwd }: { cwd: string }) { + yield* Observability.annotatePath({ label: 'pnpm', path: cwd }) const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path @@ -141,6 +145,7 @@ const discoverPnpmPackageJsonPaths = Effect.fn('workspace/discoverPnpmPackageJso const discoverManualPackageJsonPaths = Effect.fn('workspace/discoverManualPackageJsonPaths')( function* ({ cwd }: { cwd: string }) { + yield* Observability.annotatePath({ label: 'manual', path: cwd }) const pathService = yield* Path.Path const packageDirs = yield* findPackageJsonDirs({ root: cwd }) return packageDirs.map((dir) => pathService.join(dir, 'package.json')) @@ -164,6 +169,7 @@ export const resolveWorkspaceProvider = Effect.fn('workspace/resolveWorkspacePro }: { cwd: string }) { + yield* Observability.annotatePath({ label: 'provider', path: cwd }) const providerName = (process.env.GENIE_WORKSPACE_PROVIDER ?? '').toLowerCase() if (providerName === 'bun') { diff --git a/packages/@overeng/genie/tsconfig.json b/packages/@overeng/genie/tsconfig.json index 35d2dcac6..e80049707 100644 --- a/packages/@overeng/genie/tsconfig.json +++ b/packages/@overeng/genie/tsconfig.json @@ -52,6 +52,9 @@ "../../../types/css.d.ts" ], "references": [ + { + "path": "../otel-contract" + }, { "path": "../tui-core" }, diff --git a/packages/@overeng/genie/tsconfig.json.genie.ts b/packages/@overeng/genie/tsconfig.json.genie.ts index 179007fad..515c62063 100644 --- a/packages/@overeng/genie/tsconfig.json.genie.ts +++ b/packages/@overeng/genie/tsconfig.json.genie.ts @@ -19,6 +19,7 @@ export default tsconfigJson({ '../../../types/css.d.ts', ], references: [ + { path: '../otel-contract' }, { path: '../tui-core' }, { path: '../tui-react' }, { path: '../utils' }, diff --git a/packages/@overeng/megarepo/nix/build.nix b/packages/@overeng/megarepo/nix/build.nix index c1c9cb6b9..a82a0d95e 100644 --- a/packages/@overeng/megarepo/nix/build.nix +++ b/packages/@overeng/megarepo/nix/build.nix @@ -24,7 +24,7 @@ let # Managed by the repo FOD refresh workflow — do not edit manually. depsBuilds = { "." = { - hash = "sha256-cHbWgYflELDaxRmLX5HZqa5JXmsylpH7SHqZ3buNrKI="; + hash = "sha256-1f7bldN6rGvybyvQZ00pQKp24zCL9ceoxpP8dvfU2Kg="; }; }; nativeNodePackages = [ opentuiCoreNative ]; diff --git a/packages/@overeng/megarepo/package.json b/packages/@overeng/megarepo/package.json index f6d819896..0cc1e338d 100644 --- a/packages/@overeng/megarepo/package.json +++ b/packages/@overeng/megarepo/package.json @@ -35,6 +35,7 @@ "@overeng/effect-path": "workspace:^", "@overeng/kdl": "workspace:^", "@overeng/kdl-effect": "workspace:^", + "@overeng/otel-contract": "workspace:^", "@overeng/tui-react": "workspace:^", "@overeng/utils": "workspace:^", "@playwright/test": "1.59.1", @@ -108,6 +109,7 @@ "packages/@overeng/kdl", "packages/@overeng/kdl-effect", "packages/@overeng/megarepo", + "packages/@overeng/otel-contract", "packages/@overeng/tui-core", "packages/@overeng/tui-react", "packages/@overeng/utils", diff --git a/packages/@overeng/megarepo/package.json.genie.ts b/packages/@overeng/megarepo/package.json.genie.ts index 548e9b0c6..099b13c39 100644 --- a/packages/@overeng/megarepo/package.json.genie.ts +++ b/packages/@overeng/megarepo/package.json.genie.ts @@ -7,6 +7,7 @@ import { import effectPathPkg from '../effect-path/package.json.genie.ts' import kdlEffectPkg from '../kdl-effect/package.json.genie.ts' import kdlPkg from '../kdl/package.json.genie.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import tuiCorePkg from '../tui-core/package.json.genie.ts' import tuiReactPkg from '../tui-react/package.json.genie.ts' import utilsPkg from '../utils/package.json.genie.ts' @@ -23,7 +24,7 @@ const peerDepNames = [ const runtimeDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/megarepo' }), dependencies: { - workspace: [effectPathPkg, kdlPkg, kdlEffectPkg, tuiReactPkg, utilsPkg], + workspace: [effectPathPkg, kdlPkg, kdlEffectPkg, otelContractPkg, tuiReactPkg, utilsPkg], external: catalog.pick('react'), }, devDependencies: { diff --git a/packages/@overeng/megarepo/src/cli/commands/add.ts b/packages/@overeng/megarepo/src/cli/commands/add.ts index 4b8f565a8..66f0c324c 100644 --- a/packages/@overeng/megarepo/src/cli/commands/add.ts +++ b/packages/@overeng/megarepo/src/cli/commands/add.ts @@ -16,6 +16,7 @@ import { StoreLayer } from '../../lib/store.ts' import { syncMember } from '../../lib/sync/mod.ts' import { Cwd, findMegarepoRoot, outputOption, outputModeLayer } from '../context.ts' import { AddCommandError } from '../errors.ts' +import * as Observability from '../observability.ts' import { AddApp, AddView } from '../renderers/AddOutput/mod.ts' /** @@ -171,5 +172,14 @@ export const addCommand = Cli.Command.make( }), { view: React.createElement(AddView, { stateAtom: AddApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.provide(StoreLayer), Effect.withSpan('megarepo/add')), + }).pipe( + Effect.provide(StoreLayer), + Observability.withCommandSpan({ + name: 'megarepo/add', + command: 'add', + label: Option.getOrElse(name, () => parseRepoRef(repo)?.suggestedName ?? 'add'), + output, + repo, + }), + ), ).pipe(Cli.Command.withDescription('Add a new member repository')) diff --git a/packages/@overeng/megarepo/src/cli/commands/check.ts b/packages/@overeng/megarepo/src/cli/commands/check.ts index 510d3913f..06b27cb2f 100644 --- a/packages/@overeng/megarepo/src/cli/commands/check.ts +++ b/packages/@overeng/megarepo/src/cli/commands/check.ts @@ -7,6 +7,7 @@ import { readMegarepoConfig } from '../../lib/config.ts' import { LOCK_FILE_NAME, readLockFile } from '../../lib/lock.ts' import { checkSourcePolicy, formatSourcePolicyViolation } from '../../lib/source-policy.ts' import { Cwd, findMegarepoRoot, jsonOption } from '../context.ts' +import * as Observability from '../observability.ts' const allOption = Cli.Options.boolean('all').pipe( Cli.Options.withDescription('Check member source and lock files in repos/ as well as the root'), @@ -73,5 +74,13 @@ export const checkCommand = Cli.Command.make( new Error(`Megarepo checks failed with ${result.violations.length} violation(s)`), ) } - }), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/check', + command: 'check', + label: json === true ? 'check-json' : 'check', + output: json === true ? 'json' : 'text', + all, + }), + ), ).pipe(Cli.Command.withDescription('Check that the megarepo is structurally valid')) diff --git a/packages/@overeng/megarepo/src/cli/commands/config/push-refs.ts b/packages/@overeng/megarepo/src/cli/commands/config/push-refs.ts index 1addb2cd4..d6e94ef67 100644 --- a/packages/@overeng/megarepo/src/cli/commands/config/push-refs.ts +++ b/packages/@overeng/megarepo/src/cli/commands/config/push-refs.ts @@ -24,6 +24,7 @@ import { } from '../../../lib/config.ts' import { Cwd, findMegarepoRoot, outputOption, outputModeLayer } from '../../context.ts' import { NotInMegarepoError } from '../../errors.ts' +import * as Observability from '../../observability.ts' import { PushRefsApp, PushRefsView } from '../../renderers/PushRefsOutput/mod.ts' // ============================================================================= @@ -71,6 +72,12 @@ const pushRefsToNested = Effect.fn('megarepo/config/push-refs/nested')( only: Option.Option }) => Effect.gen(function* () { + yield* Observability.annotateCommand({ + label: options.nestedName, + command: 'config push-refs nested', + member: options.nestedName, + dryRun: options.dryRun, + }) const configPath = yield* findConfigPath(options.nestedRoot) if (configPath === undefined) return undefined @@ -247,7 +254,17 @@ export const pushRefsCommand = Cli.Command.make( } }), { view: React.createElement(PushRefsView, { stateAtom: PushRefsApp.stateAtom }) }, - ).pipe(Effect.provide(outputModeLayer(output)), Effect.withSpan('megarepo/config/push-refs')), + ).pipe( + Effect.provide(outputModeLayer(output)), + Observability.withCommandSpan({ + name: 'megarepo/config/push-refs', + command: 'config push-refs', + label: all === true ? 'push-refs-all' : 'push-refs', + output, + all, + dryRun, + }), + ), ).pipe( Cli.Command.withDescription( 'Propagate member refs from this megarepo to nested megarepo configs', diff --git a/packages/@overeng/megarepo/src/cli/commands/deps.ts b/packages/@overeng/megarepo/src/cli/commands/deps.ts index 1f0d5fffc..cad5535b2 100644 --- a/packages/@overeng/megarepo/src/cli/commands/deps.ts +++ b/packages/@overeng/megarepo/src/cli/commands/deps.ts @@ -15,6 +15,7 @@ import { readMegarepoConfig } from '../../lib/config.ts' import { LOCK_FILE_NAME, readLockFile } from '../../lib/lock.ts' import { buildDependencyGraph } from '../../lib/nix-lock/mod.ts' import { Cwd, findMegarepoRoot, outputOption, outputModeLayer } from '../context.ts' +import * as Observability from '../observability.ts' import { DepsApp, DepsView } from '../renderers/DepsOutput/mod.ts' import type { DepsMember } from '../renderers/DepsOutput/schema.ts' @@ -100,5 +101,11 @@ export const depsCommand = Cli.Command.make( }), { view: React.createElement(DepsView, { stateAtom: DepsApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/deps')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/deps', + command: 'deps', + output, + }), + ), ).pipe(Cli.Command.withDescription('Show the Nix input dependency graph between megarepo members.')) diff --git a/packages/@overeng/megarepo/src/cli/commands/engine.ts b/packages/@overeng/megarepo/src/cli/commands/engine.ts index 80918a316..8be723635 100644 --- a/packages/@overeng/megarepo/src/cli/commands/engine.ts +++ b/packages/@overeng/megarepo/src/cli/commands/engine.ts @@ -59,6 +59,7 @@ import { StaleLockFileError, InvalidOptionsError, } from '../errors.ts' +import * as Observability from '../observability.ts' import { SyncApp, SyncView, @@ -522,8 +523,13 @@ export const syncMegarepo = ({ lockSyncResults: nixLockResult, } satisfies MegarepoSyncResult }).pipe( - Effect.withSpan('megarepo/sync', { - attributes: { 'span.label': megarepoRoot, root: megarepoRoot, mode: options.mode, depth }, + Observability.withSyncSpan({ + megarepoRoot, + mode: options.mode, + depth, + dryRun: options.dryRun, + all: options.all, + force: options.force, }), ) diff --git a/packages/@overeng/megarepo/src/cli/commands/env.ts b/packages/@overeng/megarepo/src/cli/commands/env.ts index 16e621250..a1ce9f0d6 100644 --- a/packages/@overeng/megarepo/src/cli/commands/env.ts +++ b/packages/@overeng/megarepo/src/cli/commands/env.ts @@ -12,6 +12,7 @@ import { run } from '@overeng/tui-react' import { DEFAULT_STORE_PATH } from '../../lib/config.ts' import { outputOption, outputModeLayer } from '../context.ts' +import * as Observability from '../observability.ts' import { EnvApp, EnvView } from '../renderers/EnvOutput/mod.ts' /** Print environment variables for shell integration */ @@ -39,5 +40,13 @@ export const envCommand = Cli.Command.make( }) }), { view: React.createElement(EnvView, { stateAtom: EnvApp.stateAtom }) }, - ).pipe(Effect.provide(outputModeLayer(output)), Effect.withSpan('megarepo/env')), + ).pipe( + Effect.provide(outputModeLayer(output)), + Observability.withCommandSpan({ + name: 'megarepo/env', + command: 'env', + label: shell, + output, + }), + ), ).pipe(Cli.Command.withDescription('Output environment variables for shell integration')) diff --git a/packages/@overeng/megarepo/src/cli/commands/exec.ts b/packages/@overeng/megarepo/src/cli/commands/exec.ts index 57432a5d6..ecafe0232 100644 --- a/packages/@overeng/megarepo/src/cli/commands/exec.ts +++ b/packages/@overeng/megarepo/src/cli/commands/exec.ts @@ -13,6 +13,7 @@ import { run } from '@overeng/tui-react' import { getMemberPath, readMegarepoConfig } from '../../lib/config.ts' import { Cwd, findMegarepoRoot, outputOption, outputModeLayer, verboseOption } from '../context.ts' +import * as Observability from '../observability.ts' import { ExecApp, ExecView } from '../renderers/ExecOutput/mod.ts' /** Execution mode for running commands across members */ @@ -152,5 +153,13 @@ export const execCommand = Cli.Command.make( }), { view: React.createElement(ExecView, { stateAtom: ExecApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/exec')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/exec', + command: 'exec', + label: Option.getOrElse(member, () => 'exec'), + output, + ...(Option.isSome(member) === true ? { member: member.value } : {}), + }), + ), ).pipe(Cli.Command.withDescription('Execute a command in member directories')) diff --git a/packages/@overeng/megarepo/src/cli/commands/generate/mod.ts b/packages/@overeng/megarepo/src/cli/commands/generate/mod.ts index 7f3f33010..bd9a18619 100644 --- a/packages/@overeng/megarepo/src/cli/commands/generate/mod.ts +++ b/packages/@overeng/megarepo/src/cli/commands/generate/mod.ts @@ -16,6 +16,7 @@ import { generateSchema } from '../../../lib/generators/schema.ts' import { generateVscode } from '../../../lib/generators/vscode.ts' import { Cwd, findMegarepoRoot, outputOption, outputModeLayer } from '../../context.ts' import { GenerateError } from '../../errors.ts' +import * as Observability from '../../observability.ts' import { GenerateApp, GenerateView } from '../../renderers/GenerateOutput/mod.ts' /** Generate VSCode workspace file */ @@ -66,7 +67,14 @@ const generateVscodeCommand = Cli.Command.make( }), { view: React.createElement(GenerateView, { stateAtom: GenerateApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/generate/vscode')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/generate/vscode', + command: 'generate vscode', + label: 'vscode', + output, + }), + ), ).pipe(Cli.Command.withDescription('Generate VS Code workspace file')) /** Generate JSON Schema */ @@ -116,7 +124,14 @@ const generateSchemaCommand = Cli.Command.make( }), { view: React.createElement(GenerateView, { stateAtom: GenerateApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/generate/schema')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/generate/schema', + command: 'generate schema', + label: 'schema', + output, + }), + ), ).pipe(Cli.Command.withDescription('Generate JSON schema for megarepo.json')) /** Generate all configured outputs */ @@ -162,7 +177,14 @@ const generateAllCommand = Cli.Command.make('all', { output: outputOption }, ({ }), { view: React.createElement(GenerateView, { stateAtom: GenerateApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/generate/all')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/generate/all', + command: 'generate all', + label: 'all', + output, + }), + ), ).pipe(Cli.Command.withDescription('Generate all configured outputs')) /** Generate subcommand group */ diff --git a/packages/@overeng/megarepo/src/cli/commands/init.ts b/packages/@overeng/megarepo/src/cli/commands/init.ts index ee8d62f12..68b539922 100644 --- a/packages/@overeng/megarepo/src/cli/commands/init.ts +++ b/packages/@overeng/megarepo/src/cli/commands/init.ts @@ -14,6 +14,7 @@ import { run } from '@overeng/tui-react' import { CONFIG_FILE_NAME_KDL, findConfigPath, writeMegarepoConfig } from '../../lib/config.ts' import * as Git from '../../lib/git.ts' import { Cwd, outputOption, outputModeLayer } from '../context.ts' +import * as Observability from '../observability.ts' import { InitApp, InitView } from '../renderers/InitOutput/mod.ts' /** Initialize a new megarepo in current directory */ @@ -61,5 +62,11 @@ export const initCommand = Cli.Command.make('init', { output: outputOption }, ({ }), { view: React.createElement(InitView, { stateAtom: InitApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/init')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/init', + command: 'init', + output, + }), + ), ).pipe(Cli.Command.withDescription('Initialize a new megarepo in the current directory')) diff --git a/packages/@overeng/megarepo/src/cli/commands/ls.ts b/packages/@overeng/megarepo/src/cli/commands/ls.ts index cc13d4f02..30e131d3d 100644 --- a/packages/@overeng/megarepo/src/cli/commands/ls.ts +++ b/packages/@overeng/megarepo/src/cli/commands/ls.ts @@ -26,6 +26,7 @@ import { outputOption, outputModeLayer, } from '../context.ts' +import * as Observability from '../observability.ts' import { LsApp, LsView } from '../renderers/LsOutput/mod.ts' import type { MemberInfo } from '../renderers/LsOutput/schema.ts' @@ -168,5 +169,12 @@ export const lsCommand = Cli.Command.make( }), { view: React.createElement(LsView, { stateAtom: LsApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/ls')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/ls', + command: 'ls', + output, + all, + }), + ), ).pipe(Cli.Command.withDescription('List all members in the megarepo')) diff --git a/packages/@overeng/megarepo/src/cli/commands/pin.ts b/packages/@overeng/megarepo/src/cli/commands/pin.ts index e42acaa78..a0b671b48 100644 --- a/packages/@overeng/megarepo/src/cli/commands/pin.ts +++ b/packages/@overeng/megarepo/src/cli/commands/pin.ts @@ -46,6 +46,7 @@ import { MemberNotSyncedError, NoLockFileError, } from '../errors.ts' +import * as Observability from '../observability.ts' import { PinApp, PinView } from '../renderers/PinOutput/mod.ts' /** @@ -461,7 +462,14 @@ export const pinCommand = Cli.Command.make( { view: React.createElement(PinView, { stateAtom: PinApp.stateAtom }) }, ).pipe( Effect.provide(Layer.merge(outputModeLayer(output), StoreLayer)), - Effect.withSpan('megarepo/pin'), + Observability.withCommandSpan({ + name: 'megarepo/pin', + command: 'pin', + label: member, + output, + dryRun, + member, + }), ), ).pipe(Cli.Command.withDescription('Pin a member to a specific ref')) @@ -635,6 +643,12 @@ export const unpinCommand = Cli.Command.make( { view: React.createElement(PinView, { stateAtom: PinApp.stateAtom }) }, ).pipe( Effect.provide(Layer.merge(outputModeLayer(output), StoreLayer)), - Effect.withSpan('megarepo/unpin'), + Observability.withCommandSpan({ + name: 'megarepo/unpin', + command: 'unpin', + label: member, + output, + member, + }), ), ).pipe(Cli.Command.withDescription('Unpin a member to allow updates')) diff --git a/packages/@overeng/megarepo/src/cli/commands/root.ts b/packages/@overeng/megarepo/src/cli/commands/root.ts index 39c212bec..2093b5596 100644 --- a/packages/@overeng/megarepo/src/cli/commands/root.ts +++ b/packages/@overeng/megarepo/src/cli/commands/root.ts @@ -12,6 +12,7 @@ import { run } from '@overeng/tui-react' import * as Git from '../../lib/git.ts' import { Cwd, findMegarepoRoot, outputOption, outputModeLayer } from '../context.ts' +import * as Observability from '../observability.ts' import { RootApp, RootView } from '../renderers/RootOutput/mod.ts' /** Find and print the megarepo root directory */ @@ -48,5 +49,11 @@ export const rootCommand = Cli.Command.make('root', { output: outputOption }, ({ }), { view: React.createElement(RootView, { stateAtom: RootApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.withSpan('megarepo/root')), + }).pipe( + Observability.withCommandSpan({ + name: 'megarepo/root', + command: 'root', + output, + }), + ), ).pipe(Cli.Command.withDescription('Print the megarepo root directory')) diff --git a/packages/@overeng/megarepo/src/cli/commands/status.ts b/packages/@overeng/megarepo/src/cli/commands/status.ts index 9cf06e2b5..f78cc5eb9 100644 --- a/packages/@overeng/megarepo/src/cli/commands/status.ts +++ b/packages/@overeng/megarepo/src/cli/commands/status.ts @@ -35,6 +35,7 @@ import { outputModeLayer, } from '../context.ts' import { NotInMegarepoError } from '../errors.ts' +import * as Observability from '../observability.ts' import { StatusApp, StatusView } from '../renderers/StatusOutput/mod.ts' import type { CommitDrift, @@ -519,5 +520,13 @@ export const statusCommand = Cli.Command.make( }), { view: React.createElement(StatusView, { stateAtom: StatusApp.stateAtom }) }, ).pipe(Effect.provide(outputModeLayer(output))) - }).pipe(Effect.provide(StoreLayer), Effect.withSpan('megarepo/status')), + }).pipe( + Effect.provide(StoreLayer), + Observability.withCommandSpan({ + name: 'megarepo/status', + command: 'status', + output, + all, + }), + ), ).pipe(Cli.Command.withDescription('Show workspace status and member states')) diff --git a/packages/@overeng/megarepo/src/cli/commands/store/mod.ts b/packages/@overeng/megarepo/src/cli/commands/store/mod.ts index 559e6c754..f219fdf47 100644 --- a/packages/@overeng/megarepo/src/cli/commands/store/mod.ts +++ b/packages/@overeng/megarepo/src/cli/commands/store/mod.ts @@ -29,6 +29,7 @@ import { Store, StoreLayer } from '../../../lib/store.ts' import { getCloneUrl } from '../../../lib/sync/mod.ts' import { Cwd, findMegarepoRoot, outputOption, outputModeLayer } from '../../context.ts' import { StoreCommandError } from '../../errors.ts' +import * as Observability from '../../observability.ts' import { StoreApp, StoreView } from '../../renderers/StoreOutput/mod.ts' import type { StoreAction, @@ -251,7 +252,7 @@ const collectRepoStoreWorktrees = ({ const gitWorktreesResult = yield* Git.listWorktrees(bareRepoPath).pipe( Effect.tapError((error) => Effect.gen(function* () { - yield* Effect.annotateCurrentSpan('store.git_worktree_list.failed', true) + yield* Observability.annotateStoreGitWorktreeListFailure(true) yield* Effect.logWarning('Falling back to store layout worktree discovery').pipe( Effect.annotateLogs({ repoPath, @@ -323,12 +324,12 @@ const collectRepoStoreWorktrees = ({ return result }).pipe( - Effect.withSpan('megarepo/store/gc/collect-worktrees', { - attributes: { - 'span.label': repoPath, - 'store.repo.path': repoPath, - 'store.bare_repo.path': bareRepoPath, - }, + Observability.withStoreWorktreeSpan({ + name: 'megarepo/store/gc/collect-worktrees', + repo: repoPath, + refType: 'repo', + ref: Observability.shortPath(repoPath), + bareRepoPath, }), ) @@ -383,12 +384,13 @@ const classifyGcWorktree = ({ return { worktree, action: 'check' as const, status: statusResult.status } }).pipe( - Effect.withSpan('megarepo/store/gc/classify-worktree', { - attributes: { - 'span.label': `${worktree.refType}/${worktree.ref}`, - 'store.ref.type': worktree.refType, - 'store.worktree.broken': worktree.broken, - }, + Observability.withStoreWorktreeSpan({ + name: 'megarepo/store/gc/classify-worktree', + repo: 'worktree', + refType: worktree.refType, + ref: worktree.ref, + worktreePath: worktree.path, + broken: worktree.broken, }), ) @@ -408,7 +410,12 @@ const storeLsCommand = Cli.Command.make('ls', { output: outputOption }, ({ outpu }) }).pipe( Effect.provide(StoreLayer), - Effect.withSpan('megarepo/store/ls', { attributes: { 'span.label': 'ls' } }), + Observability.withCommandSpan({ + name: 'megarepo/store/ls', + command: 'store ls', + label: 'ls', + output, + }), ), ).pipe(Cli.Command.withDescription('List repositories in the store')) @@ -582,7 +589,12 @@ const storeStatusCommand = Cli.Command.make('status', { output: outputOption }, }) }).pipe( Effect.provide(StoreLayer), - Effect.withSpan('megarepo/store/status', { attributes: { 'span.label': 'status' } }), + Observability.withCommandSpan({ + name: 'megarepo/store/status', + command: 'store status', + label: 'status', + output, + }), ), ).pipe(Cli.Command.withDescription('Show store status and detect issues')) @@ -634,7 +646,12 @@ const storeFetchCommand = Cli.Command.make('fetch', { output: outputOption }, ({ }) }).pipe( Effect.provide(StoreLayer), - Effect.withSpan('megarepo/store/fetch', { attributes: { 'span.label': 'fetch' } }), + Observability.withCommandSpan({ + name: 'megarepo/store/fetch', + command: 'store fetch', + label: 'fetch', + output, + }), ), ).pipe(Cli.Command.withDescription('Fetch all repositories in the store')) @@ -782,14 +799,13 @@ const storeGcCommand = Cli.Command.make( status: 'removed', } satisfies StoreGcResult }).pipe( - Effect.withSpan('megarepo/store/gc/process-worktree', { - attributes: { - 'span.label': `${repoRelativePath}refs/${decision.worktree.refType}/${decision.worktree.ref}`, - 'store.repo': repoRelativePath, - 'store.ref.type': decision.worktree.refType, - 'store.worktree.path': decision.worktree.path, - 'store.bare_repo.path': bareRepoPath, - }, + Observability.withStoreWorktreeSpan({ + name: 'megarepo/store/gc/process-worktree', + repo: repoRelativePath, + refType: decision.worktree.refType, + ref: decision.worktree.ref, + worktreePath: decision.worktree.path, + bareRepoPath, }), ) @@ -959,11 +975,11 @@ const storeGcCommand = Cli.Command.make( yield* dispatchGc({ done: false, forceDispatch: true }) } }).pipe( - Effect.withSpan('megarepo/store/gc/repo', { - attributes: { - 'span.label': repo.relativePath, - 'store.repo': repo.relativePath, - }, + Observability.withStoreWorktreeSpan({ + name: 'megarepo/store/gc/repo', + repo: repo.relativePath, + refType: 'repo', + ref: Observability.shortPath(repo.relativePath), }), ), { concurrency: GC_REPO_CONCURRENCY, unordered: true }, @@ -1011,44 +1027,26 @@ const storeGcCommand = Cli.Command.make( ).pipe(Effect.provide(outputModeLayer(output as never))) } - yield* Effect.annotateCurrentSpan('gc.policy', all === true ? 'all' : 'root-set') - yield* Effect.annotateCurrentSpan( - 'gc.root_set.workspace_count', - liveSetForMetrics?.workspaceCount ?? 0, - ) - yield* Effect.annotateCurrentSpan('gc.repo.total', repoCount ?? 0) - yield* Effect.annotateCurrentSpan('gc.worktree.discovered', discoveredWorktreeCount) - yield* Effect.annotateCurrentSpan('gc.result.total', results.length) - yield* Effect.annotateCurrentSpan( - 'gc.result.removed', - results.filter((result) => result.status === 'removed').length, - ) - yield* Effect.annotateCurrentSpan( - 'gc.result.skipped_in_use', - results.filter((result) => result.status === 'skipped_in_use').length, - ) - yield* Effect.annotateCurrentSpan( - 'gc.result.skipped_dirty', - results.filter((result) => result.status === 'skipped_dirty').length, - ) - yield* Effect.annotateCurrentSpan( - 'gc.candidate.commits', - results.filter((result) => result.refType === 'commits').length, - ) - yield* Effect.annotateCurrentSpan( - 'gc.candidate.named_refs', - results.filter((result) => result.refType === 'heads' || result.refType === 'tags').length, - ) + yield* Observability.annotateStoreGcResult({ + rootSetWorkspaceCount: liveSetForMetrics?.workspaceCount ?? 0, + repoTotal: repoCount ?? 0, + worktreeDiscovered: discoveredWorktreeCount, + resultTotal: results.length, + resultRemoved: results.filter((result) => result.status === 'removed').length, + resultSkippedInUse: results.filter((result) => result.status === 'skipped_in_use').length, + resultSkippedDirty: results.filter((result) => result.status === 'skipped_dirty').length, + candidateCommits: results.filter((result) => result.refType === 'commits').length, + candidateNamedRefs: results.filter( + (result) => result.refType === 'heads' || result.refType === 'tags', + ).length, + }) }).pipe( Effect.provide(StoreLayer), - Effect.withSpan('megarepo/store/gc', { - root: true, - attributes: { - 'span.label': 'gc', - 'gc.dry_run': dryRun, - 'gc.force': force, - 'gc.all': all, - }, + Observability.withStoreGcSpan({ + policy: all === true ? 'all' : 'root-set', + dryRun, + force, + all, }), ), ).pipe(Cli.Command.withDescription('Garbage collect unused worktrees')) @@ -1208,7 +1206,10 @@ const storeAddCommand = Cli.Command.make( ).pipe(Effect.provide(outputModeLayer(output))) }).pipe( Effect.provide(StoreLayer), - Effect.withSpan('megarepo/store/add', { attributes: { 'span.label': sourceString } }), + Observability.withStoreSourceSpan({ + name: 'megarepo/store/add', + source: sourceString, + }), ), ).pipe(Cli.Command.withDescription('Add a repository to the store (without adding to megarepo)')) @@ -1322,7 +1323,14 @@ const storeFixCommand = Cli.Command.make( ).pipe(Effect.provide(outputModeLayer(output))) }).pipe( Effect.provide(StoreLayer), - Effect.withSpan('megarepo/store/fix', { attributes: { 'span.label': 'fix' } }), + Observability.withCommandSpan({ + name: 'megarepo/store/fix', + command: 'store fix', + label: Option.isSome(member) === true ? member.value : 'fix', + output, + dryRun, + ...(Option.isSome(member) === true ? { member: member.value } : {}), + }), ), ).pipe(Cli.Command.withDescription('Fix store issues')) @@ -1595,8 +1603,13 @@ const storeWorktreeNewCommand = Cli.Command.make( ).pipe(Effect.provide(outputModeLayer(output))) }).pipe( Effect.provide(StoreLayer), - Effect.withSpan('megarepo/store/worktree/new', { - attributes: { 'span.label': repoString }, + Observability.withStoreSourceSpan({ + name: 'megarepo/store/worktree/new', + source: repoString, + ...(Option.isSome(refOpt) === true ? { ref: refOpt.value } : {}), + ...(Option.isSome(baseOpt) === true ? { base: baseOpt.value } : {}), + ...(Option.isSome(commitOpt) === true ? { commit: commitOpt.value } : {}), + porcelain, }), ), ).pipe(Cli.Command.withDescription('Create a new worktree in the store')) diff --git a/packages/@overeng/megarepo/src/cli/observability.ts b/packages/@overeng/megarepo/src/cli/observability.ts new file mode 100644 index 000000000..8ea3cff47 --- /dev/null +++ b/packages/@overeng/megarepo/src/cli/observability.ts @@ -0,0 +1,409 @@ +import path from 'node:path' + +import { Effect, Schema } from 'effect' + +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + +const basename = (value: string): string => + value.split('/').findLast((part) => part.length > 0) ?? value + +export const shortRef = ({ refType, ref }: { refType: string; ref: string }): string => + `${refType}/${ref.length > 24 ? `${ref.slice(0, 12)}...${ref.slice(-8)}` : ref}` + +export const shortPath = (value: string): string => basename(value.replace(/\/+$/, '')) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchAll((error) => + typeof error === 'object' && + error !== null && + '_tag' in error && + error._tag === 'OtelAttrEncodeError' + ? Effect.die(error) + : Effect.fail(error as E), + ), + ) as Effect.Effect + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ): ((effect: Effect.Effect) => Effect.Effect) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + +const trustedAnnotate = ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, +): Effect.Effect => trustOtelContract(operation.annotate(attributes)) + +export const commandAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + command: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'megarepo.cli.command' })), + output: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'megarepo.cli.output' }))), + all: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.all' }))), + dryRun: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.dry_run' }))), + force: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.force' }))), + member: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'megarepo.member' }))), + repo: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'megarepo.repo' }))), + }), +) + +const commandOperation = ({ name, root }: { name: string; root: boolean }) => + OtelOperation.define({ + name, + attributes: commandAttrs, + label: ({ label }) => label, + ...(root === true ? { root: true } : {}), + }) + +const commandAnnotationOperation = OtelOperation.define({ + name: 'megarepo/cli/command', + attributes: commandAttrs, + label: ({ label }) => label, +}) + +const syncAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + root: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.root' })), + mode: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.sync.mode' })), + depth: Schema.Number.pipe(OtelAttr.key({ key: 'megarepo.sync.depth' })), + dryRun: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.dry_run' })), + all: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.all' })), + force: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.force' })), + }), +) + +export const syncSpan = OtelOperation.define({ + name: 'megarepo/sync', + attributes: syncAttrs, + label: ({ label }) => label, +}) + +export const storeWorktreeAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + repo: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.repo' })), + refType: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.ref_type' })), + ref: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.ref' })), + worktreePath: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.worktree_path' })), + ), + bareRepoPath: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.bare_repo_path' })), + ), + broken: Schema.optional( + Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.store.worktree_broken' })), + ), + }), +) + +const storeWorktreeOperation = (name: string) => + OtelOperation.define({ + name, + attributes: storeWorktreeAttrs, + label: ({ label }) => label, + }) + +export const storeGcAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + policy: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.gc.policy' })), + dryRun: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.dry_run' })), + force: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.force' })), + all: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.all' })), + }), +) + +const storeGcOperation = OtelOperation.define({ + name: 'megarepo/store/gc', + attributes: storeGcAttrs, + label: ({ label }) => label, + root: true, +}) + +export const storeGcResultAttrs = OtelAttrs.defineSync( + Schema.Struct({ + rootSetWorkspaceCount: Schema.Number.pipe( + OtelAttr.key({ key: 'megarepo.store.gc.root_set_workspace_count' }), + ), + repoTotal: Schema.Number.pipe(OtelAttr.key({ key: 'megarepo.store.gc.repo_total' })), + worktreeDiscovered: Schema.Number.pipe( + OtelAttr.key({ key: 'megarepo.store.gc.worktree_discovered' }), + ), + resultTotal: Schema.Number.pipe(OtelAttr.key({ key: 'megarepo.store.gc.result_total' })), + resultRemoved: Schema.Number.pipe(OtelAttr.key({ key: 'megarepo.store.gc.result_removed' })), + resultSkippedInUse: Schema.Number.pipe( + OtelAttr.key({ key: 'megarepo.store.gc.result_skipped_in_use' }), + ), + resultSkippedDirty: Schema.Number.pipe( + OtelAttr.key({ key: 'megarepo.store.gc.result_skipped_dirty' }), + ), + candidateCommits: Schema.Number.pipe( + OtelAttr.key({ key: 'megarepo.store.gc.candidate_commits' }), + ), + candidateNamedRefs: Schema.Number.pipe( + OtelAttr.key({ key: 'megarepo.store.gc.candidate_named_refs' }), + ), + }), +) + +export const storeGitWorktreeListFailureAttrs = OtelAttrs.defineSync( + Schema.Struct({ + failed: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.store.git_worktree_list_failed' })), + }), +) + +export const storeSourceAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + source: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.source' })), + ref: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.ref' }))), + base: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.base_ref' }))), + commit: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'megarepo.store.commit' }))), + porcelain: Schema.optional( + Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.cli.porcelain' })), + ), + }), +) + +const storeSourceOperation = (name: string) => + OtelOperation.define({ + name, + attributes: storeSourceAttrs, + label: ({ label }) => label, + }) + +export const withCommandSpan = ({ + name, + command, + label = command, + output, + all, + dryRun, + force, + member, + repo, + root = false, +}: { + name: string + command: string + label?: string + output?: string + all?: boolean + dryRun?: boolean + force?: boolean + member?: string + repo?: string + root?: boolean +}) => + trustedWith(commandOperation({ name, root }), { + label, + command, + ...(output === undefined ? {} : { output }), + ...(all === undefined ? {} : { all }), + ...(dryRun === undefined ? {} : { dryRun }), + ...(force === undefined ? {} : { force }), + ...(member === undefined ? {} : { member }), + ...(repo === undefined ? {} : { repo }), + }) + +export const withSyncSpan = ({ + megarepoRoot, + mode, + depth, + dryRun, + all, + force, +}: { + megarepoRoot: string + mode: string + depth: number + dryRun: boolean + all: boolean + force: boolean +}) => + trustedWith(syncSpan, { + label: shortPath(megarepoRoot), + root: megarepoRoot, + mode, + depth, + dryRun, + all, + force, + }) + +export const annotateCommand = ({ + label, + command, + output, + all, + dryRun, + force, + member, + repo, +}: { + label: string + command: string + output?: string + all?: boolean + dryRun?: boolean + force?: boolean + member?: string + repo?: string +}) => + trustedAnnotate(commandAnnotationOperation, { + label, + command, + ...(output === undefined ? {} : { output }), + ...(all === undefined ? {} : { all }), + ...(dryRun === undefined ? {} : { dryRun }), + ...(force === undefined ? {} : { force }), + ...(member === undefined ? {} : { member }), + ...(repo === undefined ? {} : { repo }), + }) + +export const annotateStoreGcResult = ( + value: Schema.Schema.Type, +) => + trustOtelContract( + OtelSpan.annotate({ attributes: storeGcResultAttrs, value }), + ) + +export const annotateStoreGitWorktreeListFailure = (failed: boolean) => + trustOtelContract( + OtelSpan.annotate({ + attributes: storeGitWorktreeListFailureAttrs, + value: { failed }, + }), + ) + +export const withStoreWorktreeSpan = ({ + name, + repo, + refType, + ref, + worktreePath, + bareRepoPath, + broken, +}: { + name: string + repo: string + refType: string + ref: string + worktreePath?: string + bareRepoPath?: string + broken?: boolean +}) => + trustedWith(storeWorktreeOperation(name), { + label: `${shortPath(repo)} ${shortRef({ refType, ref })}`, + repo, + refType, + ref, + ...(worktreePath === undefined ? {} : { worktreePath }), + ...(bareRepoPath === undefined ? {} : { bareRepoPath }), + ...(broken === undefined ? {} : { broken }), + }) + +export const withStoreGcSpan = ({ + policy, + dryRun, + force, + all, +}: { + policy: string + dryRun: boolean + force: boolean + all: boolean +}) => + trustedWith(storeGcOperation, { + label: 'gc', + policy, + dryRun, + force, + all, + }) + +export const withStoreSourceSpan = ({ + name, + source, + ref, + base, + commit, + porcelain, +}: { + name: string + source: string + ref?: string + base?: string + commit?: string + porcelain?: boolean +}) => + trustedWith(storeSourceOperation(name), { + label: shortPath(source), + source, + ...(ref === undefined ? {} : { ref }), + ...(base === undefined ? {} : { base }), + ...(commit === undefined ? {} : { commit }), + ...(porcelain === undefined ? {} : { porcelain }), + }) + +export const storeWorktree = ({ + repo, + refType, + ref, + worktreePath, + bareRepoPath, + broken, +}: { + repo: string + refType: string + ref: string + worktreePath?: string + bareRepoPath?: string + broken?: boolean +}) => + storeWorktreeAttrs.encodeSync({ + label: `${shortPath(repo)} ${shortRef({ refType, ref })}`, + repo, + refType, + ref, + ...(worktreePath === undefined ? {} : { worktreePath }), + ...(bareRepoPath === undefined ? {} : { bareRepoPath }), + ...(broken === undefined ? {} : { broken }), + }) + +export const storeSource = ({ + source, + ref, + base, + commit, + porcelain, +}: { + source: string + ref?: string + base?: string + commit?: string + porcelain?: boolean +}) => + storeSourceAttrs.encodeSync({ + label: shortPath(source), + source, + ...(ref === undefined ? {} : { ref }), + ...(base === undefined ? {} : { base }), + ...(commit === undefined ? {} : { commit }), + ...(porcelain === undefined ? {} : { porcelain }), + }) + +export const pathLabel = (value: string): string => shortPath(path.normalize(value)) diff --git a/packages/@overeng/megarepo/src/lib/git.ts b/packages/@overeng/megarepo/src/lib/git.ts index 4207b95a3..284f5806b 100644 --- a/packages/@overeng/megarepo/src/lib/git.ts +++ b/packages/@overeng/megarepo/src/lib/git.ts @@ -7,6 +7,8 @@ import { Command } from '@effect/platform' import { Cause, Chunk, Duration, Effect, Option, Schedule, Stream } from 'effect' +import * as Observability from './observability.ts' + // ============================================================================= // Git URL Parsing // ============================================================================= @@ -222,8 +224,11 @@ export const clone = (args: { url: string; targetPath: string; bare?: boolean }) cmdArgs.push(args.url, args.targetPath) yield* runGitCommandWithRetry({ args: cmdArgs }) }).pipe( - Effect.withSpan('git/clone', { - attributes: { 'span.label': args.url, url: args.url, bare: args.bare ?? false }, + Observability.withGitUrlSpan({ + name: 'git/clone', + label: args.url, + url: args.url, + bare: args.bare ?? false, }), ) @@ -238,11 +243,7 @@ export const fetch = (args: { repoPath: string; remote?: string; prune?: boolean } cmdArgs.push(args.remote ?? 'origin') yield* runGitCommandWithRetry({ args: cmdArgs, cwd: args.repoPath }) - }).pipe( - Effect.withSpan('git/fetch', { - attributes: { 'span.label': args.repoPath, repoPath: args.repoPath }, - }), - ) + }).pipe(Observability.withRepoPathSpan('git/fetch', args.repoPath)) /** * Checkout a specific ref (branch, tag, or commit) @@ -326,8 +327,9 @@ export const createWorktree = (args: { } yield* runGitCommand({ args: cmdArgs, cwd: args.repoPath }) }).pipe( - Effect.withSpan('git/create-worktree', { - attributes: { 'span.label': args.branch, branch: args.branch }, + Observability.withGitBranchSpan({ + name: 'git/create-worktree', + branch: args.branch, }), ) @@ -348,7 +350,7 @@ export const removeWorktree = (args: { repoPath: string; worktreePath: string; f export const pruneWorktrees = (repoPath: string) => runGitCommand({ args: ['worktree', 'prune'], cwd: repoPath }).pipe( Effect.asVoid, - Effect.withSpan('git/worktree-prune', { attributes: { 'span.label': repoPath, repoPath } }), + Observability.withRepoPathSpan('git/worktree-prune', repoPath), ) /** @@ -424,7 +426,11 @@ export const cloneBare = (args: { url: string; targetPath: string }) => cwd: args.targetPath, }) }).pipe( - Effect.withSpan('git/clone-bare', { attributes: { 'span.label': args.url, url: args.url } }), + Observability.withGitUrlSpan({ + name: 'git/clone-bare', + label: args.url, + url: args.url, + }), ) /** @@ -438,11 +444,7 @@ export const fetchBare = (args: { repoPath: string; remote?: string }) => args: ['fetch', '--tags', '--prune', remote], cwd: args.repoPath, }) - }).pipe( - Effect.withSpan('git/fetch-bare', { - attributes: { 'span.label': args.repoPath, repoPath: args.repoPath }, - }), - ) + }).pipe(Observability.withRepoPathSpan('git/fetch-bare', args.repoPath)) /** * Get the default branch name from a remote @@ -596,8 +598,10 @@ export const createWorktreeDetached = (args: { cwd: args.repoPath, }).pipe( Effect.asVoid, - Effect.withSpan('git/create-worktree-detached', { - attributes: { 'span.label': args.commit.slice(0, 8), commit: args.commit }, + Observability.withGitCommitSpan({ + name: 'git/create-worktree-detached', + label: args.commit.slice(0, 8), + commit: args.commit, }), ) @@ -648,8 +652,10 @@ export const getWorktreeStatus = (worktreePath: string) => changesCount: changes.length, } satisfies WorktreeStatus }).pipe( - Effect.withSpan('git/worktree-status', { - attributes: { 'span.label': worktreeSpanLabel(worktreePath), worktreePath }, + Observability.withWorktreePathSpan({ + name: 'git/worktree-status', + label: worktreeSpanLabel(worktreePath), + worktreePath, }), ) @@ -667,8 +673,10 @@ export const getWorktreeRemovalStatus = (worktreePath: string) => args: ['status', '--porcelain', '--untracked-files=normal'], cwd: worktreePath, }).pipe( - Effect.withSpan('git/worktree-removal-status/dirty', { - attributes: { 'span.label': worktreeSpanLabel(worktreePath), worktreePath }, + Observability.withWorktreePathSpan({ + name: 'git/worktree-removal-status/dirty', + label: worktreeSpanLabel(worktreePath), + worktreePath, }), ) const changes = statusOutput.split('\n').filter((line) => line.trim() !== '') @@ -682,8 +690,10 @@ export const getWorktreeRemovalStatus = (worktreePath: string) => } const hasUnpushed = yield* getUnpushedStatus(worktreePath).pipe( - Effect.withSpan('git/worktree-removal-status/unpushed', { - attributes: { 'span.label': worktreeSpanLabel(worktreePath), worktreePath }, + Observability.withWorktreePathSpan({ + name: 'git/worktree-removal-status/unpushed', + label: worktreeSpanLabel(worktreePath), + worktreePath, }), ) @@ -693,8 +703,10 @@ export const getWorktreeRemovalStatus = (worktreePath: string) => changesCount: 0, } satisfies WorktreeStatus }).pipe( - Effect.withSpan('git/worktree-removal-status', { - attributes: { 'span.label': worktreeSpanLabel(worktreePath), worktreePath }, + Observability.withWorktreePathSpan({ + name: 'git/worktree-removal-status', + label: worktreeSpanLabel(worktreePath), + worktreePath, }), ) diff --git a/packages/@overeng/megarepo/src/lib/nix-lock/mod.ts b/packages/@overeng/megarepo/src/lib/nix-lock/mod.ts index 8ade1d103..77d6a48ae 100644 --- a/packages/@overeng/megarepo/src/lib/nix-lock/mod.ts +++ b/packages/@overeng/megarepo/src/lib/nix-lock/mod.ts @@ -27,6 +27,7 @@ import { upsertLockedMember, writeLockFile, } from '../lock.ts' +import * as Observability from '../observability.ts' import { parseNixFlakeUrl, getRef, @@ -230,8 +231,10 @@ export const fetchNixFlakeMetadata = ({ lastModified: parsed.locked.lastModified, } }).pipe( - Effect.withSpan('fetchNixFlakeMetadata', { - attributes: { 'span.label': `${owner}/${repo}@${rev.slice(0, 8)}`, owner, repo, rev }, + Observability.withNixFlakeMetadataSpan({ + owner, + repo, + rev, }), ) @@ -494,8 +497,9 @@ const syncSingleLockFile = ({ updatedInputs, } }).pipe( - Effect.withSpan('megarepo/nix-lock/file', { - attributes: { 'span.label': lockPath, path: lockPath, type: lockType }, + Observability.withNixLockFileSpan({ + lockPath, + lockType, }), ) @@ -584,8 +588,9 @@ const syncNestedMegarepoLockFile = ({ updatedInputs, } satisfies NixLockSyncFileResult }).pipe( - Effect.withSpan('megarepo/nix-lock/nested', { - attributes: { 'span.label': lockPath, path: lockPath }, + Observability.withNixLockPathSpan({ + name: 'megarepo/nix-lock/nested', + path: lockPath, }), ) @@ -667,8 +672,10 @@ export const syncSourceFileRevs = ({ updatedInputs, } }).pipe( - Effect.withSpan('megarepo/nix-lock/source-file', { - attributes: { 'span.label': filePath, path: filePath, type: fileType }, + Observability.withNixLockPathTypeSpan({ + name: 'megarepo/nix-lock/source-file', + path: filePath, + type: fileType, }), ) @@ -803,7 +810,7 @@ const validateSharedInputSource = ({ } return { sourceMemberName, sourceMap } - }).pipe(Effect.withSpan('megarepo/nix-lock/shared-input-source/validate')) + }).pipe(Observability.withLabelSpan('megarepo/nix-lock/shared-input-source/validate', 'validate')) /** * Apply phase: propagate validated source inputs to all matching members. @@ -879,7 +886,7 @@ const applySharedInputSource = ({ propagatableInputs: sourceMap.size, updatedMembers, } - }).pipe(Effect.withSpan('megarepo/nix-lock/shared-input-source/apply')) + }).pipe(Observability.withLabelSpan('megarepo/nix-lock/shared-input-source/apply', 'apply')) // ============================================================================= // Ref Sync @@ -1110,11 +1117,7 @@ const syncMemberRefs = ({ yield* syncLockRefs({ filename: DEVENV_LOCK, fileType: 'devenv.lock' }) return results - }).pipe( - Effect.withSpan('megarepo/nix-lock/ref-sync', { - attributes: { 'span.label': memberPath }, - }), - ) + }).pipe(Observability.withLabelSpan('megarepo/nix-lock/ref-sync', memberPath)) // ============================================================================= // Main Sync Function diff --git a/packages/@overeng/megarepo/src/lib/observability.ts b/packages/@overeng/megarepo/src/lib/observability.ts new file mode 100644 index 000000000..18837991b --- /dev/null +++ b/packages/@overeng/megarepo/src/lib/observability.ts @@ -0,0 +1,469 @@ +import { Effect, Schema } from 'effect' + +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + +const basename = (path: string): string => + path.split('/').findLast((part) => part.length > 0) ?? path + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchAll((error) => + typeof error === 'object' && + error !== null && + '_tag' in error && + error._tag === 'OtelAttrEncodeError' + ? Effect.die(error) + : Effect.fail(error as E), + ), + ) as Effect.Effect + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ): ((effect: Effect.Effect) => Effect.Effect) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + +const labelOperation = (name: string) => + OtelOperation.define({ + name, + attributes: labelAttrs, + label: ({ label }) => label, + }) + +export const labelAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + }), +) + +export const repoPathAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + repoPath: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.repo_path' })), + }), +) + +export const worktreePathAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + worktreePath: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.worktree_path' })), + }), +) + +export const gitUrlAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + url: Schema.String.pipe(OtelAttr.key({ key: 'git.url' })), + bare: Schema.optional(Schema.Boolean.pipe(OtelAttr.key({ key: 'git.bare' }))), + }), +) + +export const gitBranchAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + branch: Schema.String.pipe(OtelAttr.key({ key: 'git.branch' })), + }), +) + +export const gitCommitAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + commit: Schema.String.pipe(OtelAttr.key({ key: 'git.commit' })), + }), +) + +export const workspaceAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + workspaceRoot: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.workspace_root' })), + }), +) + +export const storeLiveSetAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + hasCurrentWorkspace: Schema.Boolean.pipe( + OtelAttr.key({ key: 'megarepo.store.has_current_workspace' }), + ), + pruneStaleRegistry: Schema.Boolean.pipe( + OtelAttr.key({ key: 'megarepo.store.prune_stale_registry' }), + ), + refreshCurrentWorkspace: Schema.Boolean.pipe( + OtelAttr.key({ key: 'megarepo.store.refresh_current_workspace' }), + ), + }), +) + +export const nixFlakeMetadataAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + owner: Schema.String.pipe(OtelAttr.key({ key: 'nix.flake.owner' })), + repo: Schema.String.pipe(OtelAttr.key({ key: 'nix.flake.repo' })), + rev: Schema.String.pipe(OtelAttr.key({ key: 'nix.flake.rev' })), + }), +) + +export const nixLockFileAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + path: Schema.String.pipe(OtelAttr.key({ key: 'nix.lock.path' })), + type: Schema.String.pipe(OtelAttr.key({ key: 'nix.lock.type' })), + }), +) + +export const nixLockPathTypeAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + path: Schema.String.pipe(OtelAttr.key({ key: 'path' })), + type: Schema.String.pipe(OtelAttr.key({ key: 'type' })), + }), +) + +export const nixLockPathAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + path: Schema.String.pipe(OtelAttr.key({ key: 'path' })), + }), +) + +export const syncMemberCloneAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + bareExists: Schema.Boolean.pipe(OtelAttr.key({ key: 'megarepo.sync.member.bare_exists' })), + }), +) + +export const syncMemberRefAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + ref: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.sync.member.ref' })), + refType: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: 'megarepo.sync.member.ref_type' })), + ), + }), +) + +export const syncMemberAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + name: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.sync.member.name' })), + source: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.sync.member.source' })), + }), +) + +export const syncMemberActionAttrs = OtelAttrs.defineSync( + Schema.Struct({ + action: Schema.Literal( + 'clone', + 'already-cloned-by-sibling', + 'skip-dry-run', + 'fetch', + 'fetch-missing-commit', + 'noop', + ).pipe(OtelAttr.key({ key: 'megarepo.sync.member.action' })), + }), +) + +type SyncMemberAction = + | 'clone' + | 'already-cloned-by-sibling' + | 'skip-dry-run' + | 'fetch' + | 'fetch-missing-commit' + | 'noop' + +export const syncMemberResultAttrs = OtelAttrs.defineSync( + Schema.Struct({ + status: Schema.String.pipe(OtelAttr.key({ key: 'megarepo.sync.member.result_status' })), + }), +) + +export const label = (value: string) => labelAttrs.encodeSync({ label: value }) + +export const repoPath = (path: string) => + repoPathAttrs.encodeSync({ label: basename(path), repoPath: path }) + +export const worktreePath = (path: string) => + worktreePathAttrs.encodeSync({ label: basename(path), worktreePath: path }) + +export const workspaceRoot = (path: string) => + workspaceAttrs.encodeSync({ label: basename(path), workspaceRoot: path }) + +export const withLabelSpan = (name: string, labelValue: string) => + trustedWith(labelOperation(name), { label: labelValue }) + +export const withRepoPathSpan = (name: string, path: string) => + trustedWith( + OtelOperation.define({ + name, + attributes: repoPathAttrs, + label: ({ label }) => label, + }), + { label: basename(path), repoPath: path }, + ) + +const worktreePathOperation = (name: string) => + OtelOperation.define({ + name, + attributes: worktreePathAttrs, + label: ({ label }) => label, + }) + +export const withWorktreePathSpan = ({ + name, + worktreePath, + label = basename(worktreePath), +}: { + readonly name: string + readonly worktreePath: string + readonly label?: string +}) => trustedWith(worktreePathOperation(name), { label, worktreePath }) + +const gitUrlOperation = (name: string) => + OtelOperation.define({ + name, + attributes: gitUrlAttrs, + label: ({ label }) => label, + }) + +export const withGitUrlSpan = ({ + name, + label, + url, + bare, +}: { + readonly name: string + readonly label: string + readonly url: string + readonly bare?: boolean +}) => + trustedWith(gitUrlOperation(name), { + label, + url, + ...(bare === undefined ? {} : { bare }), + }) + +const gitBranchOperation = (name: string) => + OtelOperation.define({ + name, + attributes: gitBranchAttrs, + label: ({ label }) => label, + }) + +export const withGitBranchSpan = ({ + name, + branch, +}: { + readonly name: string + readonly branch: string +}) => trustedWith(gitBranchOperation(name), { label: branch, branch }) + +const gitCommitOperation = (name: string) => + OtelOperation.define({ + name, + attributes: gitCommitAttrs, + label: ({ label }) => label, + }) + +export const withGitCommitSpan = ({ + name, + label, + commit, +}: { + readonly name: string + readonly label: string + readonly commit: string +}) => trustedWith(gitCommitOperation(name), { label, commit }) + +const workspaceOperation = (name: string) => + OtelOperation.define({ + name, + attributes: workspaceAttrs, + label: ({ label }) => label, + }) + +export const withWorkspaceSpan = ({ + name, + workspaceRoot, + label = basename(workspaceRoot), +}: { + readonly name: string + readonly workspaceRoot: string + readonly label?: string +}) => trustedWith(workspaceOperation(name), { label, workspaceRoot }) + +const storeLiveSetOperation = (name: string) => + OtelOperation.define({ + name, + attributes: storeLiveSetAttrs, + label: ({ label }) => label, + }) + +export const withStoreLiveSetSpan = ({ + name, + hasCurrentWorkspace, + pruneStaleRegistry, + refreshCurrentWorkspace, +}: { + readonly name: string + readonly hasCurrentWorkspace: boolean + readonly pruneStaleRegistry: boolean + readonly refreshCurrentWorkspace: boolean +}) => + trustedWith(storeLiveSetOperation(name), { + label: 'store', + hasCurrentWorkspace, + pruneStaleRegistry, + refreshCurrentWorkspace, + }) + +const nixFlakeMetadataOperation = OtelOperation.define({ + name: 'fetchNixFlakeMetadata', + attributes: nixFlakeMetadataAttrs, + label: ({ label }) => label, +}) + +export const withNixFlakeMetadataSpan = ({ + owner, + repo, + rev, +}: { + readonly owner: string + readonly repo: string + readonly rev: string +}) => + trustedWith(nixFlakeMetadataOperation, { + label: `${owner}/${repo}@${rev.slice(0, 8)}`, + owner, + repo, + rev, + }) + +const nixLockFileOperation = OtelOperation.define({ + name: 'megarepo/nix-lock/file', + attributes: nixLockFileAttrs, + label: ({ label }) => label, +}) + +export const withNixLockFileSpan = ({ + lockPath, + lockType, +}: { + readonly lockPath: string + readonly lockType: string +}) => + trustedWith(nixLockFileOperation, { + label: basename(lockPath), + path: lockPath, + type: lockType, + }) + +const nixLockPathTypeOperation = (name: string) => + OtelOperation.define({ + name, + attributes: nixLockPathTypeAttrs, + label: ({ label }) => label, + }) + +export const withNixLockPathTypeSpan = ({ + name, + path, + type, +}: { + readonly name: string + readonly path: string + readonly type: string +}) => trustedWith(nixLockPathTypeOperation(name), { label: path, path, type }) + +const nixLockPathOperation = (name: string) => + OtelOperation.define({ + name, + attributes: nixLockPathAttrs, + label: ({ label }) => label, + }) + +export const withNixLockPathSpan = ({ + name, + path, +}: { + readonly name: string + readonly path: string +}) => trustedWith(nixLockPathOperation(name), { label: path, path }) + +const syncMemberCloneOperation = OtelOperation.define({ + name: 'megarepo/sync/member/clone-or-fetch', + attributes: syncMemberCloneAttrs, + label: ({ label }) => label, +}) + +export const withSyncMemberCloneSpan = ({ + name, + bareExists, +}: { + readonly name: string + readonly bareExists: boolean +}) => trustedWith(syncMemberCloneOperation, { label: name, bareExists }) + +const syncMemberResolveRefOperation = OtelOperation.define({ + name: 'megarepo/sync/member/resolve-ref', + attributes: syncMemberRefAttrs, + label: ({ label }) => label, +}) + +export const withSyncMemberResolveRefSpan = (ref: string) => + trustedWith(syncMemberResolveRefOperation, { label: ref, ref }) + +const syncMemberCreateWorktreeOperation = OtelOperation.define({ + name: 'megarepo/sync/member/create-worktree', + attributes: syncMemberRefAttrs, + label: ({ label }) => label, +}) + +export const withSyncMemberCreateWorktreeSpan = ({ + ref, + refType, +}: { + readonly ref: string + readonly refType: string +}) => trustedWith(syncMemberCreateWorktreeOperation, { label: ref, ref, refType }) + +const syncMemberOperation = OtelOperation.define({ + name: 'megarepo/sync/member', + attributes: syncMemberAttrs, + label: ({ label }) => label, +}) + +export const withSyncMemberSpan = ({ + name, + source, +}: { + readonly name: string + readonly source: string +}) => trustedWith(syncMemberOperation, { label: name, name, source }) + +export const annotateSyncMemberAction = (action: SyncMemberAction) => + trustOtelContract( + OtelSpan.annotate({ + attributes: syncMemberActionAttrs, + value: { action }, + }), + ) + +export const annotateSyncMemberResult = (status: string) => + trustOtelContract( + OtelSpan.annotate({ + attributes: syncMemberResultAttrs, + value: { status }, + }), + ) diff --git a/packages/@overeng/megarepo/src/lib/store-liveness.ts b/packages/@overeng/megarepo/src/lib/store-liveness.ts index 27a03af88..cdd370ead 100644 --- a/packages/@overeng/megarepo/src/lib/store-liveness.ts +++ b/packages/@overeng/megarepo/src/lib/store-liveness.ts @@ -22,6 +22,7 @@ import { readMegarepoConfig, } from './config.ts' import { LOCK_FILE_NAME, readLockFile } from './lock.ts' +import * as Observability from './observability.ts' import type { MegarepoStore } from './store.ts' const REGISTRY_VERSION = 1 @@ -98,11 +99,10 @@ const collectWorkspaceSymlinkTargets = ({ return targets }).pipe( - Effect.withSpan('megarepo/store/liveness/scan-symlinks', { - attributes: { - 'span.label': workspaceLabel(workspaceRoot), - workspaceRoot, - }, + Observability.withWorkspaceSpan({ + name: 'megarepo/store/liveness/scan-symlinks', + label: workspaceLabel(workspaceRoot), + workspaceRoot, }), ) @@ -160,11 +160,10 @@ export const collectWorkspaceLivePaths = ({ return paths }).pipe( - Effect.withSpan('megarepo/store/liveness/collect-workspace', { - attributes: { - 'span.label': workspaceLabel(workspaceRoot), - workspaceRoot, - }, + Observability.withWorkspaceSpan({ + name: 'megarepo/store/liveness/collect-workspace', + label: workspaceLabel(workspaceRoot), + workspaceRoot, }), ) @@ -198,11 +197,10 @@ export const refreshWorkspaceRegistry = ({ yield* fs.writeFileString(workspaceRecordPath({ store, workspaceRoot }), content + '\n') return record }).pipe( - Effect.withSpan('megarepo/store/liveness/refresh-workspace', { - attributes: { - 'span.label': workspaceLabel(workspaceRoot), - workspaceRoot, - }, + Observability.withWorkspaceSpan({ + name: 'megarepo/store/liveness/refresh-workspace', + label: workspaceLabel(workspaceRoot), + workspaceRoot, }), ) @@ -250,11 +248,7 @@ const readRegistryRecords = ({ } return records - }).pipe( - Effect.withSpan('megarepo/store/liveness/read-registry', { - attributes: { 'span.label': 'registry' }, - }), - ) + }).pipe(Observability.withLabelSpan('megarepo/store/liveness/read-registry', 'registry')) /** Collects the store-wide protected path set from the workspace registry. */ export const collectStoreLiveSet = ({ @@ -300,13 +294,11 @@ export const collectStoreLiveSet = ({ workspaceCount: records.length, } satisfies StoreLiveSet }).pipe( - Effect.withSpan('megarepo/store/liveness/collect-store', { - attributes: { - 'span.label': 'store', - hasCurrentWorkspace: currentWorkspaceRoot !== undefined, - pruneStaleRegistry, - refreshCurrentWorkspace, - }, + Observability.withStoreLiveSetSpan({ + name: 'megarepo/store/liveness/collect-store', + hasCurrentWorkspace: currentWorkspaceRoot !== undefined, + pruneStaleRegistry, + refreshCurrentWorkspace, }), ) diff --git a/packages/@overeng/megarepo/src/lib/store.ts b/packages/@overeng/megarepo/src/lib/store.ts index 2ecc2c5d3..056a5aa74 100644 --- a/packages/@overeng/megarepo/src/lib/store.ts +++ b/packages/@overeng/megarepo/src/lib/store.ts @@ -26,6 +26,7 @@ import { Context, Effect, Layer, Option } from 'effect' import { EffectPath, type AbsoluteDirPath, type RelativeDirPath } from '@overeng/effect-path' import { DEFAULT_STORE_PATH, ENV_VARS, getStorePath, type MemberSource } from './config.ts' +import * as Observability from './observability.ts' import { classifyRef, refTypeToPathSegment, type RefType } from './ref.ts' import { makeStoreLockLayer, StoreLock } from './store-lock.ts' @@ -304,11 +305,7 @@ const make = ({ }), ), { concurrency: 32 }, - ).pipe( - Effect.withSpan('megarepo/store/list-repos', { - attributes: { 'span.label': 'repos' }, - }), - ) + ).pipe(Observability.withLabelSpan('megarepo/store/list-repos', 'repos')) return result.toSorted((a, b) => a.relativePath.localeCompare(b.relativePath)) }), diff --git a/packages/@overeng/megarepo/src/lib/sync/member.ts b/packages/@overeng/megarepo/src/lib/sync/member.ts index 40ef2da37..d6707dc4b 100644 --- a/packages/@overeng/megarepo/src/lib/sync/member.ts +++ b/packages/@overeng/megarepo/src/lib/sync/member.ts @@ -21,6 +21,7 @@ import { import * as Git from '../git.ts' import { detectRefMismatch, formatRefMismatchMessage } from '../issues.ts' import type { LockFile } from '../lock.ts' +import * as Observability from '../observability.ts' import { classifyRef, extractRefFromSymlinkPath, isCommitSha, type RefType } from '../ref.ts' import { StoreLock } from '../store-lock.ts' import { Store } from '../store.ts' @@ -453,35 +454,31 @@ export const syncMember = ({ const repoBasePath = store.getRepoBasePath(source) yield* fs.makeDirectory(repoBasePath, { recursive: true }) yield* Git.cloneBare({ url: cloneUrl, targetPath: bareRepoPath }) - yield* Effect.annotateCurrentSpan('action', 'clone') + yield* Observability.annotateSyncMemberAction('clone') return true } - yield* Effect.annotateCurrentSpan('action', 'already-cloned-by-sibling') + yield* Observability.annotateSyncMemberAction('already-cloned-by-sibling') return false }), ) } - yield* Effect.annotateCurrentSpan('action', 'skip-dry-run') + yield* Observability.annotateSyncMemberAction('skip-dry-run') } else if (isFetchMode === true && dryRun === false) { yield* Git.fetchBare({ repoPath: bareRepoPath }).pipe(Effect.catchAll(() => Effect.void)) - yield* Effect.annotateCurrentSpan('action', 'fetch') + yield* Observability.annotateSyncMemberAction('fetch') } else if (isApplyMode === true && targetCommit !== undefined && dryRun === false) { const commitExists = yield* Git.refExists({ repoPath: bareRepoPath, ref: targetCommit }) if (commitExists === false) { yield* Git.fetchBare({ repoPath: bareRepoPath }).pipe(Effect.catchAll(() => Effect.void)) - yield* Effect.annotateCurrentSpan('action', 'fetch-missing-commit') + yield* Observability.annotateSyncMemberAction('fetch-missing-commit') } else { - yield* Effect.annotateCurrentSpan('action', 'noop') + yield* Observability.annotateSyncMemberAction('noop') } } else { - yield* Effect.annotateCurrentSpan('action', 'noop') + yield* Observability.annotateSyncMemberAction('noop') } return false - }).pipe( - Effect.withSpan('megarepo/sync/member/clone-or-fetch', { - attributes: { 'span.label': name, bareExists }, - }), - ) + }).pipe(Observability.withSyncMemberCloneSpan({ name, bareExists })) /** * A lock entry can point at an object that disappeared after a force-push. @@ -661,11 +658,7 @@ export const syncMember = ({ needsCreateBranch, defaultBranchForCreate, } - }).pipe( - Effect.withSpan('megarepo/sync/member/resolve-ref', { - attributes: { 'span.label': targetRef, ref: targetRef }, - }), - ) + }).pipe(Observability.withSyncMemberResolveRefSpan(targetRef)) if (refResult._tag === 'early-return') return refResult.result // In apply mode, use the locked commit — not the bare repo's current branch tip. @@ -790,8 +783,9 @@ export const syncMember = ({ }), ) .pipe( - Effect.withSpan('megarepo/sync/member/create-worktree', { - attributes: { 'span.label': worktreeRef, ref: worktreeRef, refType: worktreeRefType }, + Observability.withSyncMemberCreateWorktreeSpan({ + ref: worktreeRef, + refType: worktreeRefType, }), ) } @@ -952,7 +946,7 @@ export const syncMember = ({ message: branchCreatedMessage, } satisfies MemberSyncResult }).pipe( - Effect.tap((result) => Effect.annotateCurrentSpan('result.status', result.status)), + Effect.tap((result) => Observability.annotateSyncMemberResult(result.status)), Effect.catchAll((error) => { // Interpret git errors to provide user-friendly messages if (error instanceof Git.GitCommandError) { @@ -962,7 +956,7 @@ export const syncMember = ({ ? `${interpreted.message}\n hint: ${interpreted.hint}` : interpreted.message return Effect.gen(function* () { - yield* Effect.annotateCurrentSpan('result.status', 'error') + yield* Observability.annotateSyncMemberResult('error') return { name, status: 'error', @@ -971,7 +965,7 @@ export const syncMember = ({ }) } return Effect.gen(function* () { - yield* Effect.annotateCurrentSpan('result.status', 'error') + yield* Observability.annotateSyncMemberResult('error') return { name, status: 'error', @@ -979,7 +973,5 @@ export const syncMember = ({ } satisfies MemberSyncResult }) }), - Effect.withSpan('megarepo/sync/member', { - attributes: { 'span.label': name, name, source: sourceString }, - }), + Observability.withSyncMemberSpan({ name, source: sourceString }), ) diff --git a/packages/@overeng/megarepo/tsconfig.json b/packages/@overeng/megarepo/tsconfig.json index 9c1dcdd42..b440b3c8a 100644 --- a/packages/@overeng/megarepo/tsconfig.json +++ b/packages/@overeng/megarepo/tsconfig.json @@ -61,6 +61,9 @@ { "path": "../kdl-effect" }, + { + "path": "../otel-contract" + }, { "path": "../utils" } diff --git a/packages/@overeng/megarepo/tsconfig.json.genie.ts b/packages/@overeng/megarepo/tsconfig.json.genie.ts index 30fff2d60..a7b61829a 100644 --- a/packages/@overeng/megarepo/tsconfig.json.genie.ts +++ b/packages/@overeng/megarepo/tsconfig.json.genie.ts @@ -19,6 +19,7 @@ export default tsconfigJson({ { path: '../effect-path' }, { path: '../kdl' }, { path: '../kdl-effect' }, + { path: '../otel-contract' }, { path: '../utils' }, ], } satisfies TSConfigArgs) diff --git a/packages/@overeng/notion-cli/nix/build.nix b/packages/@overeng/notion-cli/nix/build.nix index 65fe21dbd..d02f522aa 100644 --- a/packages/@overeng/notion-cli/nix/build.nix +++ b/packages/@overeng/notion-cli/nix/build.nix @@ -33,7 +33,7 @@ let # Managed by the repo FOD refresh workflow — do not edit manually. depsBuilds = { "." = { - hash = "sha256-lolsDPhAJ85S8vdxgd+4FooOGJ4OOKer6yej2jBXcEY="; + hash = "sha256-CuFkj+1ti/aKBhqG8ZnJmJLHq64CKujgwVgxVneOnHo="; }; }; nativeNodePackages = [ opentuiCoreNative ]; diff --git a/packages/@overeng/notion-cli/package.json b/packages/@overeng/notion-cli/package.json index d86f1d226..92ecba020 100644 --- a/packages/@overeng/notion-cli/package.json +++ b/packages/@overeng/notion-cli/package.json @@ -40,6 +40,7 @@ "@overeng/notion-effect-client": "workspace:^", "@overeng/notion-effect-schema": "workspace:^", "@overeng/notion-md": "workspace:^", + "@overeng/otel-contract": "workspace:^", "@overeng/tui-core": "workspace:^", "@overeng/tui-react": "workspace:^", "@overeng/utils": "workspace:^", @@ -109,6 +110,7 @@ "packages/@overeng/notion-effect-client", "packages/@overeng/notion-effect-schema", "packages/@overeng/notion-md", + "packages/@overeng/otel-contract", "packages/@overeng/tui-core", "packages/@overeng/tui-react", "packages/@overeng/utils", diff --git a/packages/@overeng/notion-cli/package.json.genie.ts b/packages/@overeng/notion-cli/package.json.genie.ts index d63eb3596..164ac6875 100644 --- a/packages/@overeng/notion-cli/package.json.genie.ts +++ b/packages/@overeng/notion-cli/package.json.genie.ts @@ -9,6 +9,7 @@ import notionDatasourceSyncPkg from '../notion-datasource-sync/package.json.geni import notionEffectClientPkg from '../notion-effect-client/package.json.genie.ts' import notionEffectSchemaPkg from '../notion-effect-schema/package.json.genie.ts' import notionMdPkg from '../notion-md/package.json.genie.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import tuiCorePkg from '../tui-core/package.json.genie.ts' import tuiReactPkg from '../tui-react/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' @@ -26,6 +27,7 @@ const runtimeDeps = catalog.compose({ notionEffectClientPkg, notionEffectSchemaPkg, tuiCorePkg, + otelContractPkg, tuiReactPkg, utilsPkg, ], diff --git a/packages/@overeng/notion-cli/tsconfig.json b/packages/@overeng/notion-cli/tsconfig.json index b97c5dd78..870b5471d 100644 --- a/packages/@overeng/notion-cli/tsconfig.json +++ b/packages/@overeng/notion-cli/tsconfig.json @@ -60,6 +60,9 @@ { "path": "../notion-effect-schema" }, + { + "path": "../otel-contract" + }, { "path": "../tui-core" }, diff --git a/packages/@overeng/notion-cli/tsconfig.json.genie.ts b/packages/@overeng/notion-cli/tsconfig.json.genie.ts index 2a112167c..80fce5963 100644 --- a/packages/@overeng/notion-cli/tsconfig.json.genie.ts +++ b/packages/@overeng/notion-cli/tsconfig.json.genie.ts @@ -18,6 +18,7 @@ export default tsconfigJson({ { path: '../notion-datasource-sync' }, { path: '../notion-md' }, { path: '../notion-effect-schema' }, + { path: '../otel-contract' }, { path: '../tui-core' }, { path: '../tui-react' }, { path: '../utils' }, diff --git a/packages/@overeng/notion-datasource-sync/package.json b/packages/@overeng/notion-datasource-sync/package.json index 2a9e2eab8..57e3828a0 100644 --- a/packages/@overeng/notion-datasource-sync/package.json +++ b/packages/@overeng/notion-datasource-sync/package.json @@ -61,6 +61,7 @@ "@overeng/notion-effect-client": "workspace:^", "@overeng/notion-effect-schema": "workspace:^", "@overeng/notion-md": "workspace:^", + "@overeng/otel-contract": "workspace:^", "@overeng/tui-react": "workspace:^", "@overeng/utils": "workspace:^", "react": "19.2.3" @@ -120,6 +121,7 @@ "packages/@overeng/notion-effect-client", "packages/@overeng/notion-effect-schema", "packages/@overeng/notion-md", + "packages/@overeng/otel-contract", "packages/@overeng/tui-core", "packages/@overeng/tui-react", "packages/@overeng/utils", diff --git a/packages/@overeng/notion-datasource-sync/package.json.genie.ts b/packages/@overeng/notion-datasource-sync/package.json.genie.ts index 3bb51865d..306f9386f 100644 --- a/packages/@overeng/notion-datasource-sync/package.json.genie.ts +++ b/packages/@overeng/notion-datasource-sync/package.json.genie.ts @@ -10,6 +10,7 @@ import notionCorePkg from '../notion-core/package.json.genie.ts' import notionEffectClientPkg from '../notion-effect-client/package.json.genie.ts' import notionEffectSchemaPkg from '../notion-effect-schema/package.json.genie.ts' import notionMdPkg from '../notion-md/package.json.genie.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import tuiReactPkg from '../tui-react/package.json.genie.ts' import utilsPkg from '../utils/package.json.genie.ts' @@ -35,6 +36,7 @@ const workspaceDeps = catalog.compose({ notionEffectClientPkg, notionEffectSchemaPkg, notionMdPkg, + otelContractPkg, tuiReactPkg, utilsPkg, ], diff --git a/packages/@overeng/notion-datasource-sync/src/cli/main.ts b/packages/@overeng/notion-datasource-sync/src/cli/main.ts index 72f435042..c32bb457b 100755 --- a/packages/@overeng/notion-datasource-sync/src/cli/main.ts +++ b/packages/@overeng/notion-datasource-sync/src/cli/main.ts @@ -88,6 +88,7 @@ import { } from '../gateway/notion.ts' import { filesystemLocalWorkspacePortLayer } from '../local/workspace.ts' import { + annotateSpan, otelServiceNameForCliArgv, otelCorrelationSpanAttributes, otelServiceNames, @@ -98,6 +99,7 @@ import { spanLabel, spanNames, statusSpanAttributes, + withSpan, } from '../observability/observability.ts' import { forgetPageCommand, @@ -810,15 +812,16 @@ const runCliCommandEffect = ({ }), }), ).pipe( - Effect.withSpan(spanNames.syncInit, { - attributes: spanAttributes({ + withSpan({ + span: 'syncInit', + attributes: { [spanAttr.spanLabel]: spanLabel('init', shortSpanId(context.rootId)), [spanAttr.processRole]: processRoleForCliCommand(command._tag), [spanAttr.operation]: 'init', [spanAttr.rootId]: context.rootId, [spanAttr.dataSourceId]: command.dataSourceId, [spanAttr.dryRun]: command.dryRun === true, - }), + }, }), ) case 'pull': @@ -1192,26 +1195,24 @@ export const runCliCommand = Effect.fn(spanNames.cliCommand, { NotionDataSourceGateway | PageBodySyncPort | LocalWorkspacePort > => Effect.gen(function* () { - yield* Effect.annotateCurrentSpan( - spanAttributes({ - ...otelCorrelationSpanAttributes({ - agentRunId: process.env.OTEL_AGENT_RUN_ID, - resourceAttributes: process.env.OTEL_RESOURCE_ATTRIBUTES, - }), - [spanAttr.spanLabel]: spanLabel(command._tag), - [spanAttr.command]: command._tag, - [spanAttr.processRole]: processRoleForCliCommand(command._tag, { - watch: isWatchCommand(command), - }), - [spanAttr.rootId]: context.rootId, - [spanAttr.dataSourceId]: context.dataSourceId, - [spanAttr.dryRun]: 'dryRun' in command ? command.dryRun === true : undefined, - [spanAttr.maxCycles]: - command._tag === 'sync' && command.watch === true ? command.maxCycles : undefined, + yield* annotateSpan({ + ...otelCorrelationSpanAttributes({ + agentRunId: process.env.OTEL_AGENT_RUN_ID, + resourceAttributes: process.env.OTEL_RESOURCE_ATTRIBUTES, }), - ) + [spanAttr.spanLabel]: spanLabel(command._tag), + [spanAttr.command]: command._tag, + [spanAttr.processRole]: processRoleForCliCommand(command._tag, { + watch: isWatchCommand(command), + }), + [spanAttr.rootId]: context.rootId, + [spanAttr.dataSourceId]: context.dataSourceId, + [spanAttr.dryRun]: 'dryRun' in command ? command.dryRun === true : undefined, + [spanAttr.maxCycles]: + command._tag === 'sync' && command.watch === true ? command.maxCycles : undefined, + }) const result = yield* runCliCommandEffect({ command, context }) - yield* Effect.annotateCurrentSpan({ + yield* annotateSpan({ ...statusSpanAttributes(result.status), [spanAttr.result]: result.ok === true ? 'ok' : result.status.state, }) diff --git a/packages/@overeng/notion-datasource-sync/src/daemon/watch.ts b/packages/@overeng/notion-datasource-sync/src/daemon/watch.ts index 0c600ea64..8f3b6a0ea 100644 --- a/packages/@overeng/notion-datasource-sync/src/daemon/watch.ts +++ b/packages/@overeng/notion-datasource-sync/src/daemon/watch.ts @@ -23,6 +23,7 @@ import { reportSyncProgress } from '../core/progress.ts' import type { SignalInboxRecord } from '../core/signals.ts' import type { OneShotSyncStatus } from '../core/status.ts' import { + annotateSpan, shortSpanId, spanAttr, spanAttributes, @@ -545,17 +546,15 @@ export const runWatchDaemonCycle = Effect.fn(spanNames.daemonPass, { }) const cycle = previous.cycle + 1 const startedAt = now().toISOString() - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.spanLabel]: spanLabel('cycle', cycle), - [spanAttr.cycle]: cycle, - [spanAttr.mode]: mode, - [spanAttr.rootId]: options.rootId, - [spanAttr.dataSourceId]: options.dataSourceId, - [spanAttr.maxExecutorSteps]: options.maxExecutorSteps ?? 8, - [spanAttr.leaseDurationMs]: options.leaseDurationMs ?? 60_000, - }), - ) + yield* annotateSpan({ + [spanAttr.spanLabel]: spanLabel('cycle', cycle), + [spanAttr.cycle]: cycle, + [spanAttr.mode]: mode, + [spanAttr.rootId]: options.rootId, + [spanAttr.dataSourceId]: options.dataSourceId, + [spanAttr.maxExecutorSteps]: options.maxExecutorSteps ?? 8, + [spanAttr.leaseDurationMs]: options.leaseDurationMs ?? 60_000, + }) yield* ensureNotCancelled({ signal: options.signal, rootId: options.rootId, cycle }) yield* reportSyncProgress({ _tag: 'phase', @@ -729,7 +728,7 @@ export const runWatchDaemonCycle = Effect.fn(spanNames.daemonPass, { message: `Completed watch cycle ${cycle.toString()}`, }) - yield* Effect.annotateCurrentSpan({ + yield* annotateSpan({ ...statusSpanAttributes(sync.status), [spanAttr.result]: sync.status.state, }) @@ -782,15 +781,13 @@ export const runWatchDaemon = Effect.fn(spanNames.daemonRun, { rootId: options.rootId, statePath: options.statePath, }) - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.spanLabel]: spanLabel('watch', shortSpanId(options.rootId)), - [spanAttr.mode]: mode, - [spanAttr.rootId]: options.rootId, - [spanAttr.dataSourceId]: options.dataSourceId, - [spanAttr.maxCycles]: maxCycles, - }), - ) + yield* annotateSpan({ + [spanAttr.spanLabel]: spanLabel('watch', shortSpanId(options.rootId)), + [spanAttr.mode]: mode, + [spanAttr.rootId]: options.rootId, + [spanAttr.dataSourceId]: options.dataSourceId, + [spanAttr.maxCycles]: maxCycles, + }) for (;;) { if (maxCycles !== undefined && attempted >= maxCycles) break @@ -814,15 +811,13 @@ export const runWatchDaemon = Effect.fn(spanNames.daemonRun, { lastStatus: state.lastStatus, state, } - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.result]: 'cancelled', - [spanAttr.cancelled]: true, - [spanAttr.cycles]: result.cycles, - [spanAttr.completedCycles]: result.completed, - ...(result.lastStatus === undefined ? {} : statusSpanAttributes(result.lastStatus)), - }), - ) + yield* annotateSpan({ + [spanAttr.result]: 'cancelled', + [spanAttr.cancelled]: true, + [spanAttr.cycles]: result.cycles, + [spanAttr.completedCycles]: result.completed, + ...(result.lastStatus === undefined ? {} : statusSpanAttributes(result.lastStatus)), + }) return result } @@ -854,15 +849,13 @@ export const runWatchDaemon = Effect.fn(spanNames.daemonRun, { lastStatus: state.lastStatus, state, } - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.result]: 'cancelled', - [spanAttr.cancelled]: true, - [spanAttr.cycles]: result.cycles, - [spanAttr.completedCycles]: result.completed, - ...(result.lastStatus === undefined ? {} : statusSpanAttributes(result.lastStatus)), - }), - ) + yield* annotateSpan({ + [spanAttr.result]: 'cancelled', + [spanAttr.cancelled]: true, + [spanAttr.cycles]: result.cycles, + [spanAttr.completedCycles]: result.completed, + ...(result.lastStatus === undefined ? {} : statusSpanAttributes(result.lastStatus)), + }) return result } yield* awaitWake(delay) @@ -878,15 +871,13 @@ export const runWatchDaemon = Effect.fn(spanNames.daemonRun, { lastStatus: state.lastStatus, state, } - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.result]: 'completed', - [spanAttr.cancelled]: false, - [spanAttr.cycles]: result.cycles, - [spanAttr.completedCycles]: result.completed, - ...(result.lastStatus === undefined ? {} : statusSpanAttributes(result.lastStatus)), - }), - ) + yield* annotateSpan({ + [spanAttr.result]: 'completed', + [spanAttr.cancelled]: false, + [spanAttr.cycles]: result.cycles, + [spanAttr.completedCycles]: result.completed, + ...(result.lastStatus === undefined ? {} : statusSpanAttributes(result.lastStatus)), + }) return result }), ) diff --git a/packages/@overeng/notion-datasource-sync/src/gateway/fake.ts b/packages/@overeng/notion-datasource-sync/src/gateway/fake.ts index fda10e982..71b9dec7a 100644 --- a/packages/@overeng/notion-datasource-sync/src/gateway/fake.ts +++ b/packages/@overeng/notion-datasource-sync/src/gateway/fake.ts @@ -41,9 +41,9 @@ import { NotionDataSourceGateway, type NotionDataSourceGatewayShape } from '../c import { shortSpanId, spanAttr, - spanAttributes, spanLabel, - spanNames, + withSpan, + withStreamSpan, } from '../observability/observability.ts' import { hashStoreBytes } from '../store/projections.ts' import { @@ -132,17 +132,15 @@ const fakeGatewaySpan = (input: { const entityId = input.pageId ?? input.dataSourceId return { - attributes: spanAttributes({ - [spanAttr.spanLabel]: spanLabel( - input.operation, - entityId === undefined ? undefined : shortSpanId(entityId), - ), - [spanAttr.processRole]: 'fake-gateway', - [spanAttr.operation]: input.operation, - [spanAttr.apiVersion]: input.apiVersion, - [spanAttr.dataSourceId]: input.dataSourceId, - [spanAttr.pageId]: input.pageId, - }), + [spanAttr.spanLabel]: spanLabel( + input.operation, + entityId === undefined ? undefined : shortSpanId(entityId), + ), + [spanAttr.processRole]: 'fake-gateway', + [spanAttr.operation]: input.operation, + [spanAttr.apiVersion]: input.apiVersion, + [spanAttr.dataSourceId]: input.dataSourceId, + [spanAttr.pageId]: input.pageId, } } @@ -300,14 +298,14 @@ export const makeFakeNotionDataSourceGateway = ( apiContract, preflightCapabilities: (input) => Effect.succeed(makeCapabilityPreflightResult({ input, apiContract })).pipe( - Effect.withSpan( - spanNames.fakeGatewayRequest, - fakeGatewaySpan({ + withSpan({ + span: 'fakeGatewayRequest', + attributes: fakeGatewaySpan({ operation: 'preflightCapabilities', apiVersion: apiContract.apiVersion, dataSourceId: input.dataSourceId, }), - ), + }), ), retrieveDataSource: (id) => hasDataSourceId({ dataSourceIds: permissionAmbiguousDataSourceIds, dataSourceId: id }) === @@ -321,14 +319,14 @@ export const makeFakeNotionDataSourceGateway = ( }), ) : findDataSource({ dataSources, dataSourceId: id, operation: 'retrieveDataSource' }).pipe( - Effect.withSpan( - spanNames.fakeGatewayRequest, - fakeGatewaySpan({ + withSpan({ + span: 'fakeGatewayRequest', + attributes: fakeGatewaySpan({ operation: 'retrieveDataSource', apiVersion: apiContract.apiVersion, dataSourceId: id, }), - ), + }), ), queryRows: (input) => Stream.fromEffect( @@ -454,14 +452,14 @@ export const makeFakeNotionDataSourceGateway = ( ) : findPage({ pages, pageId: id, operation: 'retrievePage' }).pipe( Effect.map((page) => page.snapshot), - Effect.withSpan( - spanNames.fakeGatewayRequest, - fakeGatewaySpan({ + withSpan({ + span: 'fakeGatewayRequest', + attributes: fakeGatewaySpan({ operation: 'retrievePage', apiVersion: apiContract.apiVersion, pageId: id, }), - ), + }), ), retrievePageProperty: (input: RetrievePagePropertyInput) => Stream.fromEffect( @@ -547,14 +545,14 @@ export const makeFakeNotionDataSourceGateway = ( view.databaseId === input.databaseId && view.dataSourceId === input.dataSourceId, ), ).pipe( - Stream.withSpan( - spanNames.fakeGatewayRequest, - fakeGatewaySpan({ + withStreamSpan({ + span: 'fakeGatewayRequest', + attributes: fakeGatewaySpan({ operation: 'listDataSourceViews', apiVersion: apiContract.apiVersion, dataSourceId: input.dataSourceId, }), - ), + }), ), patchPageProperties: (command: PatchPagePropertiesCommand) => findPage({ pages, pageId: command.pageId, operation: 'patchPageProperties' }).pipe( diff --git a/packages/@overeng/notion-datasource-sync/src/gateway/gateway.ts b/packages/@overeng/notion-datasource-sync/src/gateway/gateway.ts index 3c69feb43..14054921d 100644 --- a/packages/@overeng/notion-datasource-sync/src/gateway/gateway.ts +++ b/packages/@overeng/notion-datasource-sync/src/gateway/gateway.ts @@ -34,9 +34,9 @@ import { commandKind, shortSpanId, spanAttr, - spanAttributes, spanLabel, - spanNames, + withSpan, + withStreamSpan, } from '../observability/observability.ts' /** The Notion API version string that this gateway implementation targets. */ @@ -197,20 +197,18 @@ const gatewayRequestSpan = (input: { const entityId = input.pageId ?? input.dataSourceId ?? input.commandId return { - attributes: spanAttributes({ - [spanAttr.spanLabel]: spanLabel( - input.operation, - entityId === undefined ? undefined : shortSpanId(entityId), - ), - [spanAttr.processRole]: 'library', - [spanAttr.operation]: input.operation, - [spanAttr.apiVersion]: input.configuredApiVersion, - [spanAttr.dataSourceId]: input.dataSourceId, - [spanAttr.pageId]: input.pageId, - [spanAttr.propertyId]: input.propertyId, - [spanAttr.commandId]: input.commandId, - [spanAttr.commandKind]: input.commandKind, - }), + [spanAttr.spanLabel]: spanLabel( + input.operation, + entityId === undefined ? undefined : shortSpanId(entityId), + ), + [spanAttr.processRole]: 'library', + [spanAttr.operation]: input.operation, + [spanAttr.apiVersion]: input.configuredApiVersion, + [spanAttr.dataSourceId]: input.dataSourceId, + [spanAttr.pageId]: input.pageId, + [spanAttr.propertyId]: input.propertyId, + [spanAttr.commandId]: input.commandId, + [spanAttr.commandKind]: input.commandKind, } } @@ -240,14 +238,14 @@ export const makeNotionDataSourceGateway = ( ) : adapter.preflightCapabilities(input), ), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'preflightCapabilities', configuredApiVersion, dataSourceId: input.dataSourceId, }), - ), + }), ), retrieveDataSource: (id) => ensureSupportedGatewayApiVersion({ @@ -256,14 +254,14 @@ export const makeNotionDataSourceGateway = ( dataSourceId: id, }).pipe( Effect.flatMap(() => adapter.retrieveDataSource(id)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'retrieveDataSource', configuredApiVersion, dataSourceId: id, }), - ), + }), ), queryRows: (input: QueryRowsInput) => Stream.fromEffect( @@ -274,14 +272,14 @@ export const makeNotionDataSourceGateway = ( }), ).pipe( Stream.flatMap(() => adapter.queryRows(input)), - Stream.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withStreamSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'queryRows', configuredApiVersion, dataSourceId: input.dataSourceId, }), - ), + }), ), retrievePage: (id) => ensureSupportedGatewayApiVersion({ @@ -290,10 +288,14 @@ export const makeNotionDataSourceGateway = ( pageId: id, }).pipe( Effect.flatMap(() => adapter.retrievePage(id)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ operation: 'retrievePage', configuredApiVersion, pageId: id }), - ), + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ + operation: 'retrievePage', + configuredApiVersion, + pageId: id, + }), + }), ), retrievePageProperty: (input: RetrievePagePropertyInput) => Stream.fromEffect( @@ -304,15 +306,15 @@ export const makeNotionDataSourceGateway = ( }), ).pipe( Stream.flatMap(() => adapter.retrievePageProperty(input)), - Stream.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withStreamSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'retrievePageProperty', configuredApiVersion, pageId: input.pageId, propertyId: input.propertyId, }), - ), + }), ), ...(adapter.listDataSourceViews === undefined ? {} @@ -326,14 +328,14 @@ export const makeNotionDataSourceGateway = ( }), ).pipe( Stream.flatMap(() => adapter.listDataSourceViews!(input)), - Stream.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withStreamSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'listDataSourceViews', configuredApiVersion, dataSourceId: input.dataSourceId, }), - ), + }), ), }), patchPageProperties: (command: PatchPagePropertiesCommand) => @@ -343,16 +345,16 @@ export const makeNotionDataSourceGateway = ( pageId: command.pageId, }).pipe( Effect.flatMap(() => adapter.patchPageProperties(command)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'patchPageProperties', configuredApiVersion, pageId: command.pageId, commandId: command.commandId, commandKind: commandKind(command._tag), }), - ), + }), ), createPage: (command: CreatePageCommand) => ensureSupportedGatewayApiVersion({ @@ -361,16 +363,16 @@ export const makeNotionDataSourceGateway = ( dataSourceId: command.dataSourceId, }).pipe( Effect.flatMap(() => adapter.createPage(command)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'createPage', configuredApiVersion, dataSourceId: command.dataSourceId, commandId: command.commandId, commandKind: commandKind(command._tag), }), - ), + }), ), patchDataSourceSchema: (command: PatchDataSourceSchemaCommand) => ensureSupportedGatewayApiVersion({ @@ -379,16 +381,16 @@ export const makeNotionDataSourceGateway = ( dataSourceId: command.dataSourceId, }).pipe( Effect.flatMap(() => adapter.patchDataSourceSchema(command)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'patchDataSourceSchema', configuredApiVersion, dataSourceId: command.dataSourceId, commandId: command.commandId, commandKind: commandKind(command._tag), }), - ), + }), ), patchDataSourceMetadata: (command: PatchDataSourceMetadataCommand) => ensureSupportedGatewayApiVersion({ @@ -397,16 +399,16 @@ export const makeNotionDataSourceGateway = ( dataSourceId: command.dataSourceId, }).pipe( Effect.flatMap(() => adapter.patchDataSourceMetadata(command)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'patchDataSourceMetadata', configuredApiVersion, dataSourceId: command.dataSourceId, commandId: command.commandId, commandKind: commandKind(command._tag), }), - ), + }), ), patchDatabaseMetadata: (command: PatchDatabaseMetadataCommand) => ensureSupportedGatewayApiVersion({ @@ -415,16 +417,16 @@ export const makeNotionDataSourceGateway = ( dataSourceId: command.dataSourceId, }).pipe( Effect.flatMap(() => adapter.patchDatabaseMetadata(command)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'patchDatabaseMetadata', configuredApiVersion, dataSourceId: command.dataSourceId, commandId: command.commandId, commandKind: commandKind(command._tag), }), - ), + }), ), trashPage: (command: TrashPageCommand) => ensureSupportedGatewayApiVersion({ @@ -433,16 +435,16 @@ export const makeNotionDataSourceGateway = ( pageId: command.pageId, }).pipe( Effect.flatMap(() => adapter.trashPage(command)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'trashPage', configuredApiVersion, pageId: command.pageId, commandId: command.commandId, commandKind: commandKind(command._tag), }), - ), + }), ), restorePage: (command: RestorePageCommand) => ensureSupportedGatewayApiVersion({ @@ -451,16 +453,16 @@ export const makeNotionDataSourceGateway = ( pageId: command.pageId, }).pipe( Effect.flatMap(() => adapter.restorePage(command)), - Effect.withSpan( - spanNames.gatewayRequest, - gatewayRequestSpan({ + withSpan({ + span: 'gatewayRequest', + attributes: gatewayRequestSpan({ operation: 'restorePage', configuredApiVersion, pageId: command.pageId, commandId: command.commandId, commandKind: commandKind(command._tag), }), - ), + }), ), } } diff --git a/packages/@overeng/notion-datasource-sync/src/observability/observability.ts b/packages/@overeng/notion-datasource-sync/src/observability/observability.ts index b49d3057b..f498ee7fa 100644 --- a/packages/@overeng/notion-datasource-sync/src/observability/observability.ts +++ b/packages/@overeng/notion-datasource-sync/src/observability/observability.ts @@ -1,4 +1,14 @@ -import type { OneShotSyncStatus } from '../core/status.ts' +import { Effect, Schema, Stream } from 'effect' + +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelAttributeValue, +} from '@overeng/otel-contract' + +import type { OneShotStatusState, OneShotSyncStatus } from '../core/status.ts' /** OTel service names used when registering the CLI and daemon tracer providers. */ export const otelServiceNames = { @@ -79,22 +89,185 @@ export const spanAttr = { statusState: 'notion.datasource.status.state', } as const +/** Canonical OTel span attribute keys emitted by this package. */ +export type SpanAttributeKey = (typeof spanAttr)[keyof typeof spanAttr] + /** Scalar types accepted as OTel span attribute values. */ -export type SpanAttributeValue = string | number | boolean +export type SpanAttributeValue = OtelAttributeValue + +type SpanAttributesInput = Partial> + +type SpanAttributesWithLabel = SpanAttributesInput & { + readonly [spanAttr.spanLabel]: string +} /** Identifies the kind of process emitting a span, recorded on `spanAttr.processRole`. */ export type ProcessRole = 'cli' | 'daemon' | 'fake-gateway' | 'library' +const SpanAttributeValueSchema = Schema.Union(Schema.String, Schema.Number, Schema.Boolean) + +const optionalAttr = (key: SpanAttributeKey) => + Schema.optional(SpanAttributeValueSchema.pipe(OtelAttr.key({ key }))) + +const SpanAttributesSchema = Schema.Struct({ + [spanAttr.agentIterationId]: optionalAttr(spanAttr.agentIterationId), + [spanAttr.apiVersion]: optionalAttr(spanAttr.apiVersion), + [spanAttr.appendedEvents]: optionalAttr(spanAttr.appendedEvents), + [spanAttr.attempt]: optionalAttr(spanAttr.attempt), + [spanAttr.blockedCount]: optionalAttr(spanAttr.blockedCount), + [spanAttr.bodyCompleteness]: optionalAttr(spanAttr.bodyCompleteness), + [spanAttr.bodyEvidenceDigest]: optionalAttr(spanAttr.bodyEvidenceDigest), + [spanAttr.bodyIdentityDigest]: optionalAttr(spanAttr.bodyIdentityDigest), + [spanAttr.bodyIdentityKind]: optionalAttr(spanAttr.bodyIdentityKind), + [spanAttr.bodyRenderedDigest]: optionalAttr(spanAttr.bodyRenderedDigest), + [spanAttr.cancelled]: optionalAttr(spanAttr.cancelled), + [spanAttr.cappedAtLimit]: optionalAttr(spanAttr.cappedAtLimit), + [spanAttr.command]: optionalAttr(spanAttr.command), + [spanAttr.commandId]: optionalAttr(spanAttr.commandId), + [spanAttr.commandKind]: optionalAttr(spanAttr.commandKind), + [spanAttr.completedCycles]: optionalAttr(spanAttr.completedCycles), + [spanAttr.conflictCount]: optionalAttr(spanAttr.conflictCount), + [spanAttr.cycle]: optionalAttr(spanAttr.cycle), + [spanAttr.cycles]: optionalAttr(spanAttr.cycles), + [spanAttr.dataSourceId]: optionalAttr(spanAttr.dataSourceId), + [spanAttr.dryRun]: optionalAttr(spanAttr.dryRun), + [spanAttr.enqueuedCommands]: optionalAttr(spanAttr.enqueuedCommands), + [spanAttr.eventCount]: optionalAttr(spanAttr.eventCount), + [spanAttr.executorSteps]: optionalAttr(spanAttr.executorSteps), + [spanAttr.guard]: optionalAttr(spanAttr.guard), + [spanAttr.incompletePropertyCount]: optionalAttr(spanAttr.incompletePropertyCount), + [spanAttr.leaseDurationMs]: optionalAttr(spanAttr.leaseDurationMs), + [spanAttr.localObservationCount]: optionalAttr(spanAttr.localObservationCount), + [spanAttr.maxCycles]: optionalAttr(spanAttr.maxCycles), + [spanAttr.maxExecutorSteps]: optionalAttr(spanAttr.maxExecutorSteps), + [spanAttr.maxStepsReached]: optionalAttr(spanAttr.maxStepsReached), + [spanAttr.mode]: optionalAttr(spanAttr.mode), + [spanAttr.operation]: optionalAttr(spanAttr.operation), + [spanAttr.outboxAmbiguousCount]: optionalAttr(spanAttr.outboxAmbiguousCount), + [spanAttr.outboxBlockedCount]: optionalAttr(spanAttr.outboxBlockedCount), + [spanAttr.outboxQueuedCount]: optionalAttr(spanAttr.outboxQueuedCount), + [spanAttr.outboxRetryableCount]: optionalAttr(spanAttr.outboxRetryableCount), + [spanAttr.outboxRunningCount]: optionalAttr(spanAttr.outboxRunningCount), + [spanAttr.pageId]: optionalAttr(spanAttr.pageId), + [spanAttr.processRole]: optionalAttr(spanAttr.processRole), + [spanAttr.propertyId]: optionalAttr(spanAttr.propertyId), + [spanAttr.queryComplete]: optionalAttr(spanAttr.queryComplete), + [spanAttr.queryPageCount]: optionalAttr(spanAttr.queryPageCount), + [spanAttr.result]: optionalAttr(spanAttr.result), + [spanAttr.rootId]: optionalAttr(spanAttr.rootId), + [spanAttr.rowCount]: optionalAttr(spanAttr.rowCount), + [spanAttr.settlementKind]: optionalAttr(spanAttr.settlementKind), + [spanAttr.spanLabel]: Schema.optional(Schema.String.pipe(OtelAttr.spanLabel())), + [spanAttr.statusState]: optionalAttr(spanAttr.statusState), +}) + +/** Schema-backed contract for package-level span attributes keyed by their emitted OTel names. */ +export const notionDatasourceSpanAttributes = OtelAttrs.defineSync(SpanAttributesSchema) + +/** Schema-backed operation contracts for the existing span catalog. */ +export const spanContracts = Object.fromEntries( + Object.entries(spanNames).map(([key, name]) => [ + key, + OtelOperation.define({ + name, + attributes: notionDatasourceSpanAttributes, + label: (attributes: typeof SpanAttributesSchema.Type) => attributes[spanAttr.spanLabel] ?? '', + }), + ]), +) as { + readonly [K in keyof typeof spanNames]: ReturnType< + typeof OtelOperation.define + > +} + +const StatusSpanAttributesSchema = Schema.Struct({ + state: Schema.Literal('clean', 'pending', 'conflict', 'blocked').pipe( + OtelAttr.key({ key: spanAttr.statusState }), + ), + blockedCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: spanAttr.blockedCount })), + conflictCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: spanAttr.conflictCount })), + outboxAmbiguousCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: spanAttr.outboxAmbiguousCount }), + ), + outboxBlockedCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: spanAttr.outboxBlockedCount }), + ), + outboxQueuedCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: spanAttr.outboxQueuedCount })), + outboxRetryableCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: spanAttr.outboxRetryableCount }), + ), + outboxRunningCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: spanAttr.outboxRunningCount }), + ), +}) + +/** Schema-backed contract for status summary attributes emitted on sync result spans. */ +export const statusSpanAttrs = OtelAttrs.defineSync(StatusSpanAttributesSchema) + +const CorrelationSpanAttributesSchema = Schema.Struct({ + agentIterationId: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: spanAttr.agentIterationId })), + ), +}) + +/** Schema-backed contract for agent correlation attributes copied onto command spans. */ +export const correlationSpanAttrs = OtelAttrs.defineSync(CorrelationSpanAttributesSchema) + /** Filters out `undefined` values from an attribute map so it can be passed directly to OTel span APIs. */ export const spanAttributes = ( - attributes: Record, + attributes: SpanAttributesInput, ): Record => - Object.fromEntries( - Object.entries(attributes).filter((entry): entry is [string, SpanAttributeValue] => { - const value = entry[1] - return value !== undefined - }), - ) + notionDatasourceSpanAttributes.encodeSync(attributes as typeof SpanAttributesSchema.Type) + +/** Attach one of this package's cataloged spans with schema-backed attributes. */ +export const withSpan = + ({ + span, + attributes, + }: { + readonly span: keyof typeof spanContracts + readonly attributes: SpanAttributesWithLabel + }) => + (effect: Effect.Effect): Effect.Effect => + spanContracts[span] + .with({ + attributes: attributes as typeof SpanAttributesSchema.Type, + effect, + }) + .pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +/** Attach one of this package's cataloged spans to a stream with schema-backed attributes. */ +export const withStreamSpan = + ({ + span, + attributes, + }: { + readonly span: keyof typeof spanContracts + readonly attributes: SpanAttributesWithLabel + }) => + (stream: Stream.Stream): Stream.Stream => + spanContracts[span] + .withStream({ + attributes: attributes as typeof SpanAttributesSchema.Type, + stream, + }) + .pipe( + Stream.catchAll((error) => + typeof error === 'object' && + error !== null && + '_tag' in error && + error._tag === 'OtelAttrEncodeError' + ? Stream.die(error) + : Stream.fail(error as E), + ), + ) + +/** Annotate the active span using this package's schema-backed attribute contract. */ +export const annotateSpan = (attributes: SpanAttributesInput): Effect.Effect => + OtelSpan.annotate({ + attributes: notionDatasourceSpanAttributes, + value: attributes as typeof SpanAttributesSchema.Type, + }).pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) /** Truncates a span / root ID to at most 12 characters for use in human-readable `span.label` values. */ export const shortSpanId = (value: string): string => @@ -140,15 +313,15 @@ export const otelServiceNameForCliArgv = (argv: ReadonlyArray): string = export const statusSpanAttributes = ( status: OneShotSyncStatus, ): Record => - spanAttributes({ - [spanAttr.statusState]: status.state, - [spanAttr.blockedCount]: status.counts.blocked, - [spanAttr.conflictCount]: status.counts.conflict, - [spanAttr.outboxAmbiguousCount]: status.counts.outbox.ambiguous, - [spanAttr.outboxBlockedCount]: status.counts.outbox.blocked, - [spanAttr.outboxQueuedCount]: status.counts.outbox.queued, - [spanAttr.outboxRetryableCount]: status.counts.outbox.retryable, - [spanAttr.outboxRunningCount]: status.counts.outbox.running, + statusSpanAttrs.encodeSync({ + state: status.state satisfies OneShotStatusState, + blockedCount: status.counts.blocked, + conflictCount: status.counts.conflict, + outboxAmbiguousCount: status.counts.outbox.ambiguous, + outboxBlockedCount: status.counts.outbox.blocked, + outboxQueuedCount: status.counts.outbox.queued, + outboxRetryableCount: status.counts.outbox.retryable, + outboxRunningCount: status.counts.outbox.running, }) const resourceAttributeValue = ({ @@ -175,8 +348,8 @@ export const otelCorrelationSpanAttributes = (input: { readonly agentRunId?: string | undefined readonly resourceAttributes?: string | undefined }): Record => - spanAttributes({ - [spanAttr.agentIterationId]: + correlationSpanAttrs.encodeSync({ + agentIterationId: input.agentRunId ?? resourceAttributeValue({ input: input.resourceAttributes, key: spanAttr.agentIterationId }), }) diff --git a/packages/@overeng/notion-datasource-sync/src/observability/observability.unit.test.ts b/packages/@overeng/notion-datasource-sync/src/observability/observability.unit.test.ts index 714491830..809d4b97b 100644 --- a/packages/@overeng/notion-datasource-sync/src/observability/observability.unit.test.ts +++ b/packages/@overeng/notion-datasource-sync/src/observability/observability.unit.test.ts @@ -3,13 +3,20 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { testIds } from '../testing/harness.ts' import { + correlationSpanAttrs, + notionDatasourceSpanAttributes, otelCorrelationSpanAttributes, otelServiceNameForCliArgv, otelServiceNames, spanAttr, + spanAttributes, + spanContracts, spanLabel, spanNames, + statusSpanAttrs, + statusSpanAttributes, } from './observability.ts' const source = (name: string): string => @@ -65,6 +72,88 @@ describe('notion datasource sync observability', () => { expect(spanLabel('retrievePageProperty', 'page-1234567890abcdef', 'prop')).toHaveLength(39) }) + it('backs the package attribute helper with the schema-first OTEL contract', () => { + expect(notionDatasourceSpanAttributes.keys.has(spanAttr.spanLabel)).toBe(true) + expect(notionDatasourceSpanAttributes.keys.has(spanAttr.processRole)).toBe(true) + expect(notionDatasourceSpanAttributes.keys.has(spanAttr.statusState)).toBe(true) + expect(notionDatasourceSpanAttributes.hasSpanLabel).toBe(true) + + expect( + spanAttributes({ + [spanAttr.spanLabel]: 'sync:root-1', + [spanAttr.processRole]: 'library', + [spanAttr.operation]: 'sync', + [spanAttr.rootId]: undefined, + [spanAttr.rowCount]: 3, + }), + ).toEqual({ + [spanAttr.spanLabel]: 'sync:root-1', + [spanAttr.processRole]: 'library', + [spanAttr.operation]: 'sync', + [spanAttr.rowCount]: 3, + }) + }) + + it('keeps every span name coupled to a schema-backed span contract', () => { + expect(Object.keys(spanContracts)).toEqual(Object.keys(spanNames)) + expect( + Object.fromEntries( + Object.entries(spanContracts).map(([key, contract]) => [key, contract.name]), + ), + ).toEqual(spanNames) + + for (const contract of Object.values(spanContracts)) { + expect(contract.attributes.hasSpanLabel).toBe(true) + } + }) + + it('uses focused schemas for status and correlation attributes', () => { + expect(statusSpanAttrs.keys.has(spanAttr.statusState)).toBe(true) + expect(correlationSpanAttrs.keys.has(spanAttr.agentIterationId)).toBe(true) + + expect( + statusSpanAttributes({ + rootId: testIds.rootId, + binding: undefined, + state: 'blocked', + counts: { + clean: 0, + pending: 0, + conflict: 1, + blocked: 2, + outbox: { + ambiguous: 3, + blocked: 4, + queued: 5, + retryable: 6, + running: 7, + fenced: 8, + settled: 9, + }, + projections: { dataSources: 0, rows: 0, properties: 0, bodies: 0 }, + tombstones: { unclassified: 0 }, + guards: { blocked: 0 }, + capabilities: { unsupported: 0 }, + checkpoints: { + incompleteQueries: 0, + cappedQueries: 0, + changedQueryContracts: 0, + incompleteProperties: 0, + }, + }, + }), + ).toMatchObject({ + [spanAttr.statusState]: 'blocked', + [spanAttr.blockedCount]: 2, + [spanAttr.conflictCount]: 1, + [spanAttr.outboxAmbiguousCount]: 3, + [spanAttr.outboxBlockedCount]: 4, + [spanAttr.outboxQueuedCount]: 5, + [spanAttr.outboxRetryableCount]: 6, + [spanAttr.outboxRunningCount]: 7, + }) + }) + it('keeps otel run correlation queryable on the command span', () => { expect( otelCorrelationSpanAttributes({ @@ -88,6 +177,16 @@ describe('notion datasource sync observability', () => { expect(directStringSpans).toEqual([]) }) + it('routes raw Effect OTEL APIs through the package observability helpers', () => { + const rawOtelCalls = instrumentedSources.flatMap(([name, text]) => + [...text.matchAll(/(?:Effect|Stream)\.(?:withSpan|annotateCurrentSpan)\(/g)].map( + (match) => `${name}:${match[0]}`, + ), + ) + + expect(rawOtelCalls).toEqual([]) + }) + it('keeps CLI, one-shot, and daemon spans wired through the shared catalog', () => { expect(source('cli/main.ts')).toContain('Effect.fn(spanNames.cliCommand, {') expect(source('cli/main.ts')).toContain( diff --git a/packages/@overeng/notion-datasource-sync/src/sync/executor.ts b/packages/@overeng/notion-datasource-sync/src/sync/executor.ts index 0441d51cb..5901645ad 100644 --- a/packages/@overeng/notion-datasource-sync/src/sync/executor.ts +++ b/packages/@overeng/notion-datasource-sync/src/sync/executor.ts @@ -16,12 +16,13 @@ import type { GuardName } from '../core/guards.ts' import { NotionDataSourceGateway, PageBodySyncPort } from '../core/ports.ts' import { notionRequestId } from '../gateway/gateway.ts' import { + annotateSpan, commandKind as otelCommandKind, shortSpanId, spanAttr, - spanAttributes, spanLabel, spanNames, + withSpan, } from '../observability/observability.ts' import { hashStoreBytes, pageLifecycleHash } from '../store/projections.ts' import { @@ -139,7 +140,7 @@ const commandDataSourceId = (command: RemoteWriteCommand): string | undefined => const bodyPointerSpanAttributes = (pointer: BodyPointer | undefined) => pointer === undefined ? {} - : spanAttributes({ + : { [spanAttr.bodyIdentityKind]: pointer.identity._tag, [spanAttr.bodyIdentityDigest]: bodyPointerIdentityDigest(pointer), [spanAttr.bodyRenderedDigest]: renderedBodyDigest(pointer.identity), @@ -151,28 +152,27 @@ const bodyPointerSpanAttributes = (pointer: BodyPointer | undefined) => pointer.identity._tag === 'EvidenceBackedBodyIdentity' ? pointer.identity.completeness : undefined, - }) + } const commandSpanAttributes = (input: { readonly operation: string readonly command: RemoteWriteCommand -}) => - spanAttributes({ - [spanAttr.spanLabel]: spanLabel( - input.operation, - otelCommandKind(input.command._tag), - shortSpanId(input.command.commandId), - ), - [spanAttr.processRole]: 'library', - [spanAttr.operation]: input.operation, - [spanAttr.commandId]: input.command.commandId, - [spanAttr.commandKind]: otelCommandKind(input.command._tag), - [spanAttr.dataSourceId]: commandDataSourceId(input.command), - [spanAttr.pageId]: commandPageId(input.command), - ...bodyPointerSpanAttributes( - input.command._tag === 'BodyPushCommand' ? input.command.baseBodyPointer : undefined, - ), - }) +}) => ({ + [spanAttr.spanLabel]: spanLabel( + input.operation, + otelCommandKind(input.command._tag), + shortSpanId(input.command.commandId), + ), + [spanAttr.processRole]: 'library', + [spanAttr.operation]: input.operation, + [spanAttr.commandId]: input.command.commandId, + [spanAttr.commandKind]: otelCommandKind(input.command._tag), + [spanAttr.dataSourceId]: commandDataSourceId(input.command), + [spanAttr.pageId]: commandPageId(input.command), + ...bodyPointerSpanAttributes( + input.command._tag === 'BodyPushCommand' ? input.command.baseBodyPointer : undefined, + ), +}) const commandBaseHash = (command: RemoteWriteCommand): Hash => { switch (command._tag) { @@ -287,7 +287,8 @@ const observeCurrentSurface = ( } } }).pipe( - Effect.withSpan(spanNames.outboxObserveSurface, { + withSpan({ + span: 'outboxObserveSurface', attributes: commandSpanAttributes({ operation: 'observeCurrentSurface', command, @@ -350,7 +351,8 @@ const executeRemoteWrite = ( } } }).pipe( - Effect.withSpan(spanNames.outboxWriteRemote, { + withSpan({ + span: 'outboxWriteRemote', attributes: commandSpanAttributes({ operation: 'executeRemoteWrite', command, @@ -456,13 +458,11 @@ const settle = ({ ) const annotateOutboxResult = (result: OutboxExecutionResult) => - Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.result]: result._tag, - [spanAttr.guard]: result._tag === 'failed' ? result.guard : undefined, - [spanAttr.settlementKind]: result._tag === 'settled' ? result.settlementKind : undefined, - }), - ) + annotateSpan({ + [spanAttr.result]: result._tag, + [spanAttr.guard]: result._tag === 'failed' ? result.guard : undefined, + [spanAttr.settlementKind]: result._tag === 'settled' ? result.settlementKind : undefined, + }) /** Claim and attempt to execute one pending outbox command: observe the current surface, execute the write if safe, then verify the post-write state. Returns `idle` when the outbox is empty. */ export const executeOutboxOnce = Effect.fn(spanNames.outboxAttempt)( @@ -474,15 +474,13 @@ export const executeOutboxOnce = Effect.fn(spanNames.outboxAttempt)( NotionDataSourceGateway | PageBodySyncPort > => Effect.gen(function* () { - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.spanLabel]: spanLabel('outbox', shortSpanId(options.rootId)), - [spanAttr.processRole]: 'library', - [spanAttr.operation]: 'executeOutboxOnce', - [spanAttr.rootId]: options.rootId, - [spanAttr.leaseDurationMs]: options.leaseDurationMs, - }), - ) + yield* annotateSpan({ + [spanAttr.spanLabel]: spanLabel('outbox', shortSpanId(options.rootId)), + [spanAttr.processRole]: 'library', + [spanAttr.operation]: 'executeOutboxOnce', + [spanAttr.rootId]: options.rootId, + [spanAttr.leaseDurationMs]: options.leaseDurationMs, + }) const claimed = yield* storeEffect({ operation: 'claim-next-outbox-command', f: () => options.store.claimNextOutboxCommand(options), @@ -490,20 +488,18 @@ export const executeOutboxOnce = Effect.fn(spanNames.outboxAttempt)( if (claimed === undefined) { const result = { _tag: 'idle' as const } - yield* Effect.annotateCurrentSpan({ + yield* annotateSpan({ [spanAttr.spanLabel]: spanLabel('outbox', 'idle'), }) yield* annotateOutboxResult(result) return result } - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.spanLabel]: spanLabel('outbox', shortSpanId(claimed.commandId)), - [spanAttr.commandId]: claimed.commandId, - [spanAttr.attempt]: claimed.attempt, - }), - ) + yield* annotateSpan({ + [spanAttr.spanLabel]: spanLabel('outbox', shortSpanId(claimed.commandId)), + [spanAttr.commandId]: claimed.commandId, + [spanAttr.attempt]: claimed.attempt, + }) if (claimed.command === undefined) { const result = yield* recordAttemptState({ @@ -517,17 +513,15 @@ export const executeOutboxOnce = Effect.fn(spanNames.outboxAttempt)( } const command = claimed.command - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.spanLabel]: spanLabel( - otelCommandKind(command._tag), - shortSpanId(command.commandId), - ), - [spanAttr.commandKind]: otelCommandKind(command._tag), - [spanAttr.dataSourceId]: commandDataSourceId(command), - [spanAttr.pageId]: commandPageId(command), - }), - ) + yield* annotateSpan({ + [spanAttr.spanLabel]: spanLabel( + otelCommandKind(command._tag), + shortSpanId(command.commandId), + ), + [spanAttr.commandKind]: otelCommandKind(command._tag), + [spanAttr.dataSourceId]: commandDataSourceId(command), + [spanAttr.pageId]: commandPageId(command), + }) const before = yield* observeCurrentSurface(command).pipe( Effect.catchAll((error) => recordAttemptState({ diff --git a/packages/@overeng/notion-datasource-sync/src/sync/sync.ts b/packages/@overeng/notion-datasource-sync/src/sync/sync.ts index c502b30b6..96c6a4c8a 100644 --- a/packages/@overeng/notion-datasource-sync/src/sync/sync.ts +++ b/packages/@overeng/notion-datasource-sync/src/sync/sync.ts @@ -29,9 +29,9 @@ import { import { reportSyncProgress } from '../core/progress.ts' import { readOneShotSyncStatus, type OneShotSyncStatus } from '../core/status.ts' import { + annotateSpan, shortSpanId, spanAttr, - spanAttributes, spanLabel, spanNames, statusSpanAttributes, @@ -377,18 +377,16 @@ const annotateOneShotStart = (input: { readonly maxExecutorSteps?: number readonly leaseDurationMs?: number }) => - Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.spanLabel]: spanLabel(input.operation, shortSpanId(input.rootId)), - [spanAttr.processRole]: 'library', - [spanAttr.operation]: input.operation, - [spanAttr.rootId]: input.rootId, - [spanAttr.dataSourceId]: input.dataSourceId, - [spanAttr.dryRun]: input.dryRun === true, - [spanAttr.maxExecutorSteps]: input.maxExecutorSteps, - [spanAttr.leaseDurationMs]: input.leaseDurationMs, - }), - ) + annotateSpan({ + [spanAttr.spanLabel]: spanLabel(input.operation, shortSpanId(input.rootId)), + [spanAttr.processRole]: 'library', + [spanAttr.operation]: input.operation, + [spanAttr.rootId]: input.rootId, + [spanAttr.dataSourceId]: input.dataSourceId, + [spanAttr.dryRun]: input.dryRun === true, + [spanAttr.maxExecutorSteps]: input.maxExecutorSteps, + [spanAttr.leaseDurationMs]: input.leaseDurationMs, + }) const resumeCursorForPull = (options: OneShotPullOptions) => { const expectedQueryContractHash = computeQueryContractHash({ @@ -418,13 +416,11 @@ const disappearanceCandidateEvents = Effect.fn(spanNames.syncQueryAbsence)( readonly observation: RemoteObservationResult }) => Effect.gen(function* () { - yield* Effect.annotateCurrentSpan( - spanAttributes({ - [spanAttr.spanLabel]: spanLabel('query-absence', shortSpanId(options.rootId)), - [spanAttr.rootId]: options.rootId, - [spanAttr.dataSourceId]: options.dataSourceId, - }), - ) + yield* annotateSpan({ + [spanAttr.spanLabel]: spanLabel('query-absence', shortSpanId(options.rootId)), + [spanAttr.rootId]: options.rootId, + [spanAttr.dataSourceId]: options.dataSourceId, + }) if ( observation.query.startCursor !== null || observation.query.complete === false || @@ -565,7 +561,7 @@ export const pullOneShotSync = Effect.fn(spanNames.syncPull)( appendedEvents, status: readOneShotSyncStatus({ store: options.store, rootId: options.rootId }), } - yield* Effect.annotateCurrentSpan({ + yield* annotateSpan({ ...statusSpanAttributes(result.status), [spanAttr.appendedEvents]: appendedEvents, [spanAttr.cappedAtLimit]: observation.query.cappedAtLimit, @@ -605,7 +601,7 @@ export const establishFromNotion = Effect.fn(spanNames.syncEstablishFromNotion)( const binding = initOneShotSync(options) const pull = yield* pullOneShotSync(options) const status = readOneShotSyncStatus({ store: options.store, rootId: options.rootId }) - yield* Effect.annotateCurrentSpan({ + yield* annotateSpan({ ...statusSpanAttributes(status), [spanAttr.appendedEvents]: pull.appendedEvents, [spanAttr.queryComplete]: pull.observation.query.complete, @@ -808,7 +804,7 @@ export const pushOneShotSync = Effect.fn(spanNames.syncPush)( }, status: readOneShotSyncStatus({ store: options.store, rootId: options.rootId }), } - yield* Effect.annotateCurrentSpan({ + yield* annotateSpan({ ...statusSpanAttributes(result.status), [spanAttr.appendedEvents]: result.plan.appendedEvents, [spanAttr.blockedCount]: result.plan.blocked, @@ -882,7 +878,7 @@ export const syncOneShot = Effect.fn(spanNames.syncOneShot)( const status = readOneShotSyncStatus({ store: options.store, rootId: options.rootId }) yield* reportSyncProgress({ _tag: 'phase', phase: 'complete' }) - yield* Effect.annotateCurrentSpan({ + yield* annotateSpan({ ...statusSpanAttributes(status), [spanAttr.appendedEvents]: pull.appendedEvents + push.plan.appendedEvents, [spanAttr.blockedCount]: push.plan.blocked, diff --git a/packages/@overeng/notion-datasource-sync/tsconfig.json b/packages/@overeng/notion-datasource-sync/tsconfig.json index 461a5e968..52784fd46 100644 --- a/packages/@overeng/notion-datasource-sync/tsconfig.json +++ b/packages/@overeng/notion-datasource-sync/tsconfig.json @@ -60,6 +60,9 @@ { "path": "../notion-md" }, + { + "path": "../otel-contract" + }, { "path": "../tui-react" }, diff --git a/packages/@overeng/notion-datasource-sync/tsconfig.json.genie.ts b/packages/@overeng/notion-datasource-sync/tsconfig.json.genie.ts index 34a1ddfa1..478e5a162 100644 --- a/packages/@overeng/notion-datasource-sync/tsconfig.json.genie.ts +++ b/packages/@overeng/notion-datasource-sync/tsconfig.json.genie.ts @@ -19,6 +19,7 @@ export default tsconfigJson({ { path: '../notion-effect-client' }, { path: '../notion-effect-schema' }, { path: '../notion-md' }, + { path: '../otel-contract' }, { path: '../tui-react' }, { path: '../utils' }, ], diff --git a/packages/@overeng/notion-effect-client/package.json b/packages/@overeng/notion-effect-client/package.json index a547b4c80..6857838c4 100644 --- a/packages/@overeng/notion-effect-client/package.json +++ b/packages/@overeng/notion-effect-client/package.json @@ -16,7 +16,8 @@ "dependencies": { "@overeng/content-address": "workspace:^", "@overeng/notion-core": "workspace:^", - "@overeng/notion-effect-schema": "workspace:^" + "@overeng/notion-effect-schema": "workspace:^", + "@overeng/otel-contract": "workspace:^" }, "devDependencies": { "@effect/platform": "0.96.1", @@ -47,6 +48,7 @@ "packages/@overeng/notion-core", "packages/@overeng/notion-effect-client", "packages/@overeng/notion-effect-schema", + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/notion-effect-client/package.json.genie.ts b/packages/@overeng/notion-effect-client/package.json.genie.ts index 2f2704cd0..6318968a6 100644 --- a/packages/@overeng/notion-effect-client/package.json.genie.ts +++ b/packages/@overeng/notion-effect-client/package.json.genie.ts @@ -7,13 +7,14 @@ import { import contentAddressPkg from '../content-address/package.json.genie.ts' import notionCorePkg from '../notion-core/package.json.genie.ts' import notionEffectSchemaPkg from '../notion-effect-schema/package.json.genie.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' import utilsPkg from '../utils/package.json.genie.ts' const runtimeDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/notion-effect-client' }), dependencies: { - workspace: [contentAddressPkg, notionCorePkg, notionEffectSchemaPkg], + workspace: [contentAddressPkg, notionCorePkg, notionEffectSchemaPkg, otelContractPkg], }, devDependencies: { workspace: [utilsDevPkg, utilsPkg], diff --git a/packages/@overeng/notion-effect-client/src/databases.ts b/packages/@overeng/notion-effect-client/src/databases.ts index 2bd48f15d..4f7bd8270 100644 --- a/packages/@overeng/notion-effect-client/src/databases.ts +++ b/packages/@overeng/notion-effect-client/src/databases.ts @@ -11,6 +11,7 @@ import { import type { NotionConfig } from './config.ts' import type { NotionApiError } from './error.ts' import { get, patch, post } from './internal/http.ts' +import { withNotionDatabasesQuerySpan } from './internal/otel.ts' import { paginate, PaginatedResponse, @@ -307,11 +308,7 @@ const queryRaw = ( responseSchema: QueryDatabaseResponseSchema, }) return toPaginatedResult(response) - }).pipe( - Effect.withSpan('NotionDatabases.query', { - attributes: { 'notion.data_source_id': opts.dataSourceId }, - }), - ) + }).pipe(withNotionDatabasesQuerySpan(opts.dataSourceId)) /** * Query a database with filters and pagination. diff --git a/packages/@overeng/notion-effect-client/src/internal/http.ts b/packages/@overeng/notion-effect-client/src/internal/http.ts index 34e10d207..44d911c01 100644 --- a/packages/@overeng/notion-effect-client/src/internal/http.ts +++ b/packages/@overeng/notion-effect-client/src/internal/http.ts @@ -17,6 +17,7 @@ import { import { NOTION_API_BASE_URL, NOTION_API_VERSION, NotionConfig } from '../config.ts' import { NotionApiError, NotionErrorResponse } from '../error.ts' +import { annotateNotionHttpRateLimitSpan, withNotionHttpSpan } from './otel.ts' import { NotionThrottle } from './throttle.ts' /** Rate limit info extracted from response headers */ @@ -268,27 +269,7 @@ const annotateRateLimitSpan = (input: { readonly attempts: number readonly retryDelayMs?: number readonly rateLimit: Option.Option -}): Effect.Effect => - Effect.annotateCurrentSpan( - definedSpanAttributes({ - 'span.label': input.route.spanLabel, - 'notion.http.method': input.method, - 'notion.http.route': input.route.route, - 'notion.http.operation': input.route.operation, - 'notion.http.status_code': input.status, - 'notion.http.retry.attempt': input.attempt, - 'notion.http.retry.attempts': input.attempts, - 'notion.http.retry.delay_ms': input.retryDelayMs, - 'notion.quota.cost': input.attempts, - 'notion.rate_limit.present': Option.isSome(input.rateLimit), - 'notion.rate_limit.remaining': Option.getOrUndefined( - Option.map(input.rateLimit, (rateLimit) => rateLimit.remaining), - ), - 'notion.rate_limit.reset_after_ms': Option.getOrUndefined( - Option.map(input.rateLimit, (rateLimit) => rateLimit.resetAfterSeconds * 1000), - ), - }), - ) +}): Effect.Effect => annotateNotionHttpRateLimitSpan(input) const reportHttpTelemetry = (event: NotionHttpTelemetryEvent): Effect.Effect => Effect.serviceOption(NotionHttpTelemetry).pipe( @@ -300,16 +281,6 @@ const reportHttpTelemetry = (event: NotionHttpTelemetryEvent): Effect.Effect, -): Record => - Object.fromEntries( - Object.entries(attributes).filter((entry): entry is [string, string | number | boolean] => { - const value = entry[1] - return value !== undefined - }), - ) - /** * Build a Notion API request with proper headers. */ @@ -351,7 +322,7 @@ export const buildRequest = ({ return baseRequest }) -/* No `Effect.withSpan` here: request construction is a trivial synchronous +/* No operation span here: request construction is a trivial synchronous shape (header mapping + body encoding) and the generated span was noise — 85 zero-signal spans per `pixeltrail sync` run. Method/path are already carried on the surrounding `NotionHttp.` span. */ @@ -598,16 +569,7 @@ export const executeRequest = ({ onNone: () => runOnce, onSome: (service) => service.apply(runOnce), }) - }).pipe( - Effect.withSpan(`NotionHttp.${method}`, { - attributes: { - 'span.label': notionHttpRouteInfo({ method, path }).spanLabel, - 'notion.http.method': method, - 'notion.http.route': notionHttpRouteInfo({ method, path }).route, - 'notion.http.operation': notionHttpRouteInfo({ method, path }).operation, - }, - }), - ) + }).pipe(withNotionHttpSpan({ method, route: notionHttpRouteInfo({ method, path }) })) /** Options for GET request */ export interface GetRequestOptions { diff --git a/packages/@overeng/notion-effect-client/src/internal/otel.ts b/packages/@overeng/notion-effect-client/src/internal/otel.ts new file mode 100644 index 000000000..a327d4493 --- /dev/null +++ b/packages/@overeng/notion-effect-client/src/internal/otel.ts @@ -0,0 +1,146 @@ +import { Effect, Option, Schema } from 'effect' + +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + +import type { BuildRequestOptions, NotionHttpRouteInfo, RateLimitInfo } from './http.ts' + +const Method = Schema.Literal('GET', 'POST', 'PATCH', 'DELETE') + +const HttpSpanAttrs = OtelAttrs.defineSync( + Schema.Struct({ + spanLabel: OtelAttr.drop(Schema.NonEmptyString), + method: Method.pipe(OtelAttr.key({ key: 'notion.http.method' })), + route: Schema.String.pipe(OtelAttr.key({ key: 'notion.http.route' })), + operation: Schema.String.pipe(OtelAttr.key({ key: 'notion.http.operation' })), + }), +) + +const HttpRateLimitAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + method: Method.pipe(OtelAttr.key({ key: 'notion.http.method' })), + route: Schema.String.pipe(OtelAttr.key({ key: 'notion.http.route' })), + operation: Schema.String.pipe(OtelAttr.key({ key: 'notion.http.operation' })), + status: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'notion.http.status_code' }))), + attempt: Schema.Number.pipe(OtelAttr.key({ key: 'notion.http.retry.attempt' })), + attempts: Schema.Number.pipe(OtelAttr.key({ key: 'notion.http.retry.attempts' })), + retryDelayMs: Schema.optional( + Schema.Number.pipe(OtelAttr.key({ key: 'notion.http.retry.delay_ms' })), + ), + quotaCost: Schema.Number.pipe(OtelAttr.key({ key: 'notion.quota.cost' })), + rateLimitPresent: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion.rate_limit.present' })), + rateLimitRemaining: Schema.optional( + Schema.Number.pipe(OtelAttr.key({ key: 'notion.rate_limit.remaining' })), + ), + rateLimitResetAfterMs: Schema.optional( + Schema.Number.pipe(OtelAttr.key({ key: 'notion.rate_limit.reset_after_ms' })), + ), + }), +) + +const DataSourceQueryAttrs = OtelAttrs.defineSync( + Schema.Struct({ + dataSourceId: Schema.String.pipe(OtelAttr.key({ key: 'notion.data_source_id' })), + }), +) + +const PageRetrieveAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion.page_id' })), + }), +) + +const NotionHttpSpan = (method: BuildRequestOptions['method']) => + OtelOperation.define({ + name: `NotionHttp.${method}`, + attributes: HttpSpanAttrs, + label: ({ spanLabel }) => spanLabel, + }) + +const NotionDatabasesQuerySpan = OtelOperation.define({ + name: 'NotionDatabases.query', + attributes: DataSourceQueryAttrs, + label: ({ dataSourceId }) => dataSourceId, +}) + +const NotionPagesRetrieveSpan = OtelOperation.define({ + name: 'NotionPages.retrieve', + attributes: PageRetrieveAttrs, + label: ({ pageId }) => pageId, +}) + +const withOperation = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + operation.with(attributes), + Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error)), + ) + +const annotateAttrs = ( + attributes: OtelAttrs, + value: Schema.Schema.Type, +): Effect.Effect => + OtelSpan.annotate({ attributes, value }).pipe( + Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error)), + ) + +export const withNotionHttpSpan = + ({ + method, + route, + }: { + readonly method: BuildRequestOptions['method'] + readonly route: NotionHttpRouteInfo + }) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + withOperation(NotionHttpSpan(method), { + spanLabel: route.spanLabel, + method, + route: route.route, + operation: route.operation, + }), + ) + +export const annotateNotionHttpRateLimitSpan = (input: { + readonly method: BuildRequestOptions['method'] + readonly route: NotionHttpRouteInfo + readonly status?: number + readonly attempt: number + readonly attempts: number + readonly retryDelayMs?: number + readonly rateLimit: Option.Option +}): Effect.Effect => { + const rateLimit = input.rateLimit + const isSome = Option.isSome(rateLimit) + return annotateAttrs(HttpRateLimitAttrs, { + label: input.route.spanLabel, + method: input.method, + route: input.route.route, + operation: input.route.operation, + status: input.status, + attempt: input.attempt, + attempts: input.attempts, + retryDelayMs: input.retryDelayMs, + quotaCost: input.attempts, + rateLimitPresent: isSome, + rateLimitRemaining: isSome ? rateLimit.value.remaining : undefined, + rateLimitResetAfterMs: isSome ? rateLimit.value.resetAfterSeconds * 1000 : undefined, + }) +} + +export const withNotionDatabasesQuerySpan = (dataSourceId: string) => + withOperation(NotionDatabasesQuerySpan, { dataSourceId }) + +export const withNotionPagesRetrieveSpan = (pageId: string) => + withOperation(NotionPagesRetrieveSpan, { pageId }) diff --git a/packages/@overeng/notion-effect-client/src/pages.ts b/packages/@overeng/notion-effect-client/src/pages.ts index 5a5cf1824..687a45f94 100644 --- a/packages/@overeng/notion-effect-client/src/pages.ts +++ b/packages/@overeng/notion-effect-client/src/pages.ts @@ -13,6 +13,7 @@ import type { NotionConfig } from './config.ts' import type { NotionApiError } from './error.ts' import { NotionApiError as NotionApiErrorClass } from './error.ts' import { get, patch, post } from './internal/http.ts' +import { withNotionPagesRetrieveSpan } from './internal/otel.ts' import { type PaginatedResult, toPaginatedResult } from './internal/pagination.ts' import { decodePage, type PageDecodeError, type TypedPage } from './typed-page.ts' @@ -229,11 +230,7 @@ export function retrieve( } return page - }).pipe( - Effect.withSpan('NotionPages.retrieve', { - attributes: { 'notion.page_id': opts.pageId }, - }), - ) + }).pipe(withNotionPagesRetrieveSpan(opts.pageId)) } /** Retrieve a single page property by id — handles both single-value and paginated list-shaped property responses (`GET /v1/pages/{page_id}/properties/{property_id}`). */ diff --git a/packages/@overeng/notion-effect-client/tsconfig.json b/packages/@overeng/notion-effect-client/tsconfig.json index 102795dd5..55ed6e1ec 100644 --- a/packages/@overeng/notion-effect-client/tsconfig.json +++ b/packages/@overeng/notion-effect-client/tsconfig.json @@ -53,6 +53,9 @@ { "path": "../notion-effect-schema" }, + { + "path": "../otel-contract" + }, { "path": "../utils" }, diff --git a/packages/@overeng/notion-effect-client/tsconfig.json.genie.ts b/packages/@overeng/notion-effect-client/tsconfig.json.genie.ts index 07b350cfd..fe220620e 100644 --- a/packages/@overeng/notion-effect-client/tsconfig.json.genie.ts +++ b/packages/@overeng/notion-effect-client/tsconfig.json.genie.ts @@ -14,6 +14,7 @@ export default tsconfigJson({ { path: '../content-address' }, { path: '../notion-core' }, { path: '../notion-effect-schema' }, + { path: '../otel-contract' }, { path: '../utils' }, { path: '../utils-dev' }, ], diff --git a/packages/@overeng/notion-md/nix/build.nix b/packages/@overeng/notion-md/nix/build.nix index d4d365b62..cba993b79 100644 --- a/packages/@overeng/notion-md/nix/build.nix +++ b/packages/@overeng/notion-md/nix/build.nix @@ -20,7 +20,7 @@ let # Managed by the repo FOD refresh workflow — do not edit manually. depsBuilds = { "." = { - hash = "sha256-5mI9kBtPx2ndAzPrvg3BFd3E1pUud31ac15mxC1L19Q="; + hash = "sha256-2V8S6/AKbZ1bG32UbmAkcrgmNZDJq2+BNh17fLCWkRk="; }; }; smokeTestArgs = [ "--help" ]; diff --git a/packages/@overeng/notion-md/package.json b/packages/@overeng/notion-md/package.json index 2c72485bd..5da9e02c6 100644 --- a/packages/@overeng/notion-md/package.json +++ b/packages/@overeng/notion-md/package.json @@ -29,6 +29,7 @@ "@overeng/notion-core": "workspace:^", "@overeng/notion-effect-client": "workspace:^", "@overeng/notion-effect-schema": "workspace:^", + "@overeng/otel-contract": "workspace:^", "@overeng/utils": "workspace:^", "remark-gfm": "4.0.1", "remark-parse": "11.0.0", @@ -86,6 +87,7 @@ "packages/@overeng/notion-effect-client", "packages/@overeng/notion-effect-schema", "packages/@overeng/notion-md", + "packages/@overeng/otel-contract", "packages/@overeng/tui-core", "packages/@overeng/tui-react", "packages/@overeng/utils", diff --git a/packages/@overeng/notion-md/package.json.genie.ts b/packages/@overeng/notion-md/package.json.genie.ts index 4f1c49719..c26082d47 100644 --- a/packages/@overeng/notion-md/package.json.genie.ts +++ b/packages/@overeng/notion-md/package.json.genie.ts @@ -9,6 +9,7 @@ import contentAddressPkg from '../content-address/package.json.genie.ts' import notionCorePkg from '../notion-core/package.json.genie.ts' import notionEffectClientPkg from '../notion-effect-client/package.json.genie.ts' import notionEffectSchemaPkg from '../notion-effect-schema/package.json.genie.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import tuiReactPkg from '../tui-react/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' import utilsPkg from '../utils/package.json.genie.ts' @@ -34,6 +35,7 @@ const workspaceDeps = catalog.compose({ notionCorePkg, notionEffectClientPkg, notionEffectSchemaPkg, + otelContractPkg, utilsPkg, ], external: catalog.pick( diff --git a/packages/@overeng/notion-md/src/batch.ts b/packages/@overeng/notion-md/src/batch.ts index 8ce102801..05c19e74a 100644 --- a/packages/@overeng/notion-md/src/batch.ts +++ b/packages/@overeng/notion-md/src/batch.ts @@ -1,11 +1,14 @@ import { basename, dirname, resolve } from 'node:path' import { FileSystem, Path } from '@effect/platform' -import { Duration, Effect, Queue, Stream } from 'effect' +import { Duration, Effect, Queue, Schema, Stream } from 'effect' + +import { OtelAttr, OtelOperation } from '@overeng/otel-contract' import { NmdCliError, NmdFileSystemError, type NmdError } from './errors.ts' import { parseNmdFile } from './frontmatter.ts' import type { NotionMdGateway } from './model.ts' +import { withOperation } from './observability.ts' import { NmdStateStore } from './state-store.ts' import { statusPage, @@ -20,6 +23,33 @@ const WATCH_DEBOUNCE = Duration.millis(250) const SKIPPED_DIRECTORIES = new Set(['.git', '.notion-md', 'node_modules']) +const BatchOperationSchema = Schema.Literal('status', 'sync') + +const BatchRunSpanAttrs = Schema.Struct({ + command: BatchOperationSchema.pipe(OtelAttr.key({ key: 'notion_md.command' })), + batch: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.batch' })), + targetCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion_md.batch.target_count' })), + recursive: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.batch.recursive' })), +}) + +const batchRunSpan = (operation: BatchOperation) => + OtelOperation.define({ + name: `notion-md.${operation}-many`, + schema: BatchRunSpanAttrs, + label: ({ targetCount }) => `${targetCount} target(s)`, + }) + +const BatchWatchSpan = OtelOperation.define({ + name: 'notion-md.batch-watch', + schema: Schema.Struct({ + command: Schema.Literal('sync').pipe(OtelAttr.key({ key: 'notion_md.command' })), + watch: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.watch' })), + batch: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.batch' })), + pathCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion_md.batch.path_count' })), + }), + label: ({ pathCount }) => `${pathCount} file(s)`, +}) + /** Batch-capable page operation names. */ export type BatchOperation = 'status' | 'sync' @@ -382,14 +412,11 @@ const runBatch = (opts: { items: [...resolved.errors, ...preflight.errors, ...operationItems], }) }).pipe( - Effect.withSpan(`notion-md.${opts.operation}-many`, { - attributes: { - 'span.label': `${opts.targets.length} target(s)`, - 'notion_md.command': opts.operation, - 'notion_md.batch': true, - 'notion_md.batch.target_count': opts.targets.length, - 'notion_md.batch.recursive': opts.recursive === true, - }, + withOperation(batchRunSpan(opts.operation), { + command: opts.operation, + batch: true, + targetCount: opts.targets.length, + recursive: opts.recursive === true, }), ) @@ -570,14 +597,11 @@ export const runBatchWatch = ( ) }), ).pipe( - Effect.withSpan('notion-md.batch-watch', { - attributes: { - 'span.label': `${opts.paths.length} file(s)`, - 'notion_md.command': 'sync', - 'notion_md.watch': true, - 'notion_md.batch': true, - 'notion_md.batch.path_count': opts.paths.length, - }, + withOperation(BatchWatchSpan, { + command: 'sync', + watch: true, + batch: true, + pathCount: opts.paths.length, }), ) diff --git a/packages/@overeng/notion-md/src/cli-program.ts b/packages/@overeng/notion-md/src/cli-program.ts index 9181bd82e..c292982b6 100644 --- a/packages/@overeng/notion-md/src/cli-program.ts +++ b/packages/@overeng/notion-md/src/cli-program.ts @@ -6,6 +6,7 @@ import { Cause, Console, Duration, Effect, Layer, Option, Queue, Schema, Stream import { NotionConfigLive, resolveNotionToken } from '@overeng/notion-effect-client' import { parseNotionUuid } from '@overeng/notion-effect-schema' +import { OtelAttr, OtelAttrs, OtelOperation } from '@overeng/otel-contract' import { resolveCliVersion } from '@overeng/utils/node/cli-version' import { @@ -18,6 +19,7 @@ import { import { NmdCliError, NmdTokenMissingError } from './errors.ts' import { NotionMdGatewayLive } from './live.ts' import type { NotionMdGateway } from './model.ts' +import { annotateAttrs, withOperation } from './observability.ts' import { planPath, statusPath, syncPath, targetKind } from './path.ts' import { NmdStateStoreLive, type NmdStateStore } from './state-store.ts' import { pullPage, syncPage, type SyncOptions } from './sync.ts' @@ -31,6 +33,55 @@ const PositiveInteger = Schema.Number.pipe(Schema.int(), Schema.positive()).anno identifier: 'NotionMd.Cli.PositiveInteger', }) +const WatchReasonSchema = Schema.Literal('file', 'initial', 'poll') + +const WatchSyncResultAttrs = OtelAttrs.defineSync( + Schema.Struct({ + result: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.sync.result' })), + reason: WatchReasonSchema.pipe(OtelAttr.key({ key: 'notion_md.watch.reason' })), + }), +) + +const WatchSyncErrorAttrs = OtelAttrs.defineSync( + Schema.Struct({ + error: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.sync.error' })), + errorTag: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.sync.error_tag' })), + }), +) + +const WatchSyncPassSpan = OtelOperation.define({ + name: 'notion-md.watch.sync-pass', + root: true, + schema: Schema.Struct({ + command: Schema.Literal('sync').pipe(OtelAttr.key({ key: 'notion_md.command' })), + watch: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.watch' })), + reason: WatchReasonSchema.pipe(OtelAttr.key({ key: 'notion_md.watch.reason' })), + basename: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + }), + label: ({ basename, reason }) => `${basename}:${reason}`, +}) + +const WatchSpan = OtelOperation.define({ + name: 'notion-md.watch', + schema: Schema.Struct({ + command: Schema.Literal('sync').pipe(OtelAttr.key({ key: 'notion_md.command' })), + watch: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.watch' })), + basename: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + }), + label: ({ basename }) => basename, +}) + +const cliCommandSpan = (command: string) => + OtelOperation.define({ + name: `notion-md.cli.${command}`, + root: true, + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + command: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'notion_md.command' })), + }), + label: ({ label }) => label, + }) + const nmdTargetsArg = Args.text({ name: 'target' }).pipe( Args.withDescription('Local .nmd file path or directory with --recursive'), Args.withSchema(NonEmptyCliText), @@ -227,16 +278,16 @@ export const runWatch = (opts: { const pass = (reason: WatchReason) => syncPage(opts.syncOptions).pipe( Effect.tap((result) => - Effect.annotateCurrentSpan({ - 'notion_md.sync.result': result._tag, - 'notion_md.watch.reason': reason, + annotateAttrs(WatchSyncResultAttrs, { + result: result._tag, + reason, }), ), Effect.tap((result) => emit({ event: 'sync', reason, result })), Effect.tapError((error: unknown) => - Effect.annotateCurrentSpan({ - 'notion_md.sync.error': true, - 'notion_md.sync.error_tag': + annotateAttrs(WatchSyncErrorAttrs, { + error: true, + errorTag: typeof error === 'object' && error !== null && '_tag' in error ? String((error as { readonly _tag?: unknown })._tag) : error instanceof Error @@ -244,15 +295,11 @@ export const runWatch = (opts: { : 'unknown', }), ), - Effect.withSpan('notion-md.watch.sync-pass', { - root: true, - attributes: { - 'span.label': `${watchedFile}:${reason}`, - 'notion_md.command': 'sync', - 'notion_md.watch': true, - 'notion_md.watch.reason': reason, - 'notion_md.path.basename': watchedFile, - }, + withOperation(WatchSyncPassSpan, { + command: 'sync', + watch: true, + reason, + basename: watchedFile, }), Effect.catchAll((error: unknown) => emit({ event: 'sync_error', reason, error: safeJsonError(error) }), @@ -294,13 +341,10 @@ export const runWatch = (opts: { ) }), ).pipe( - Effect.withSpan('notion-md.watch', { - attributes: { - 'span.label': basename(opts.syncOptions.path), - 'notion_md.command': 'sync', - 'notion_md.watch': true, - 'notion_md.path.basename': basename(opts.syncOptions.path), - }, + withOperation(WatchSpan, { + command: 'sync', + watch: true, + basename: basename(opts.syncOptions.path), }), ) @@ -310,12 +354,9 @@ const commandSpan = (opts: { readonly effect: Effect.Effect }): Effect.Effect => opts.effect.pipe( - Effect.withSpan(`notion-md.cli.${opts.command}`, { - root: true, - attributes: { - 'span.label': opts.label, - 'notion_md.command': opts.command, - }, + withOperation(cliCommandSpan(opts.command), { + label: opts.label, + command: opts.command, }), ) diff --git a/packages/@overeng/notion-md/src/cli.e2e.test.ts b/packages/@overeng/notion-md/src/cli.e2e.test.ts index 5fb8fcfb7..1e6118ded 100644 --- a/packages/@overeng/notion-md/src/cli.e2e.test.ts +++ b/packages/@overeng/notion-md/src/cli.e2e.test.ts @@ -9,12 +9,13 @@ import { describe, expect, it } from 'vitest' const execFileAsync = promisify(execFile) const packageDir = fileURLToPath(new URL('..', import.meta.url)) -const cliTestTimeoutMs = 15_000 +const cliProcessTimeoutMs = 20_000 +const cliTestTimeoutMs = 25_000 const runCli = (args: readonly string[]) => execFileAsync('bun', ['src/cli.ts', ...args], { cwd: packageDir, - timeout: 10_000, + timeout: cliProcessTimeoutMs, env: { ...process.env, NOTION_API_TOKEN: '', diff --git a/packages/@overeng/notion-md/src/live.ts b/packages/@overeng/notion-md/src/live.ts index 11d0aac45..18114bc7c 100644 --- a/packages/@overeng/notion-md/src/live.ts +++ b/packages/@overeng/notion-md/src/live.ts @@ -23,6 +23,7 @@ import { type RemoteChildPage, type RemotePageSnapshot, } from './model.ts' +import * as Observability from './observability.ts' /* * Notion's title property is named "title" on standalone pages but is named @@ -237,9 +238,7 @@ export const NotionMdGatewayLive = Layer.effect( } }).pipe( Effect.mapError(mapGatewayError({ operation: 'pull_page', pageId })), - Effect.withSpan('notion-md.gateway.pull-page', { - attributes: { 'span.label': pageId.slice(0, 8), 'notion_md.page_id': pageId }, - }), + Observability.withOperation(Observability.GatewayPullPageSpan, { pageId }), ), updateMarkdown: ({ pageId, command, allowDeletingContent }) => provideHttp( @@ -293,24 +292,19 @@ export const NotionMdGatewayLive = Layer.effect( ? cause : mapGatewayError({ operation: 'update_markdown', pageId })(cause), ), - Effect.withSpan('notion-md.gateway.update-markdown', { - attributes: { - 'span.label': pageId.slice(0, 8), - 'notion_md.page_id': pageId, - 'notion_md.markdown_update.type': command._tag, - 'notion_md.markdown_update.allow_deleting_content': allowDeletingContent, - 'notion_md.markdown_update.content_update_count': - command._tag === 'update_content' ? command.contentUpdates.length : 0, - }, + Observability.withOperation(Observability.GatewayUpdateMarkdownSpan, { + pageId, + type: command._tag, + allowDeletingContent, + contentUpdateCount: + command._tag === 'update_content' ? command.contentUpdates.length : 0, }), ), updatePageProperties: ({ pageId, properties }) => provideHttp(NotionPages.update({ pageId, properties })).pipe( Effect.map(toRemotePage), Effect.mapError(mapGatewayError({ operation: 'update_page_properties', pageId })), - Effect.withSpan('notion-md.gateway.update-page-properties', { - attributes: { 'span.label': pageId.slice(0, 8), 'notion_md.page_id': pageId }, - }), + Observability.withOperation(Observability.GatewayUpdatePagePropertiesSpan, { pageId }), ), updatePageMetadata: ({ pageId, metadata }) => provideHttp( @@ -340,16 +334,13 @@ export const NotionMdGatewayLive = Layer.effect( ).pipe( Effect.map(toRemotePage), Effect.mapError(mapGatewayError({ operation: 'update_page_metadata', pageId })), - Effect.withSpan('notion-md.gateway.update-page-metadata', { - attributes: { - 'span.label': pageId.slice(0, 8), - 'notion_md.page_id': pageId, - 'notion_md.page_metadata.title': metadata.title !== undefined, - 'notion_md.page_metadata.icon': metadata.icon !== undefined, - 'notion_md.page_metadata.cover': metadata.cover !== undefined, - 'notion_md.page_metadata.in_trash': metadata.in_trash !== undefined, - 'notion_md.page_metadata.is_locked': metadata.is_locked !== undefined, - }, + Observability.withOperation(Observability.GatewayUpdatePageMetadataSpan, { + pageId, + hasTitle: metadata.title !== undefined, + hasIcon: metadata.icon !== undefined, + hasCover: metadata.cover !== undefined, + inTrash: metadata.in_trash !== undefined, + isLocked: metadata.is_locked !== undefined, }), ), listChildPages: ({ pageId }) => @@ -364,9 +355,7 @@ export const NotionMdGatewayLive = Layer.effect( }), ), Effect.mapError(mapGatewayError({ operation: 'list_child_pages', pageId })), - Effect.withSpan('notion-md.gateway.list-child-pages', { - attributes: { 'span.label': pageId.slice(0, 8), 'notion_md.page_id': pageId }, - }), + Observability.withOperation(Observability.GatewayListChildPagesSpan, { pageId }), ), createPage: ({ parentPageId, title, markdown }) => provideHttp( @@ -383,12 +372,7 @@ export const NotionMdGatewayLive = Layer.effect( ).pipe( Effect.map(toRemotePage), Effect.mapError(mapGatewayError({ operation: 'create_page', pageId: parentPageId })), - Effect.withSpan('notion-md.gateway.create-page', { - attributes: { - 'span.label': parentPageId.slice(0, 8), - 'notion_md.parent_page_id': parentPageId, - }, - }), + Observability.withOperation(Observability.GatewayCreatePageSpan, { parentPageId }), ), movePage: ({ pageId, parentPageId }) => provideHttp( @@ -396,17 +380,13 @@ export const NotionMdGatewayLive = Layer.effect( ).pipe( Effect.map(toRemotePage), Effect.mapError(mapGatewayError({ operation: 'move_page', pageId })), - Effect.withSpan('notion-md.gateway.move-page', { - attributes: { 'span.label': pageId.slice(0, 8), 'notion_md.page_id': pageId }, - }), + Observability.withOperation(Observability.GatewayMovePageSpan, { pageId }), ), archivePage: ({ pageId }) => provideHttp(NotionPages.update({ pageId, in_trash: true })).pipe( Effect.map(toRemotePage), Effect.mapError(mapGatewayError({ operation: 'archive_page', pageId })), - Effect.withSpan('notion-md.gateway.archive-page', { - attributes: { 'span.label': pageId.slice(0, 8), 'notion_md.page_id': pageId }, - }), + Observability.withOperation(Observability.GatewayArchivePageSpan, { pageId }), ), } }), diff --git a/packages/@overeng/notion-md/src/observability.ts b/packages/@overeng/notion-md/src/observability.ts new file mode 100644 index 000000000..283538577 --- /dev/null +++ b/packages/@overeng/notion-md/src/observability.ts @@ -0,0 +1,324 @@ +import { basename } from 'node:path' + +import { Effect, Schema } from 'effect' + +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + +export const pageAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.page_id' })), + }), +) + +export const parentPageAttrs = OtelAttrs.defineSync( + Schema.Struct({ + parentPageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.parent_page_id' })), + }), +) + +export const pathAttrs = OtelAttrs.defineSync( + Schema.Struct({ + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + }), +) + +export const pathRecursiveAttrs = OtelAttrs.defineSync( + Schema.Struct({ + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + recursive: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.path.recursive' })), + }), +) + +export const pathPlanAttrs = OtelAttrs.defineSync( + Schema.Struct({ + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + fromRemote: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.tree.from_remote' })), + }), +) + +export const pathSyncAttrs = OtelAttrs.defineSync( + Schema.Struct({ + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + recursive: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.path.recursive' })), + fromRemote: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.tree.from_remote' })), + }), +) + +export const pagePathAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.page_id' })), + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + }), +) + +export const statusAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.page_id' })), + localChanged: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.status.local_changed' })), + localPageMetadataChanged: Schema.Boolean.pipe( + OtelAttr.key({ key: 'notion_md.status.local_page_metadata_changed' }), + ), + localPropertiesChanged: Schema.Boolean.pipe( + OtelAttr.key({ key: 'notion_md.status.local_properties_changed' }), + ), + remoteChanged: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.status.remote_changed' })), + remoteBodyChanged: Schema.Boolean.pipe( + OtelAttr.key({ key: 'notion_md.status.remote_body_changed' }), + ), + remotePageMetadataChanged: Schema.Boolean.pipe( + OtelAttr.key({ key: 'notion_md.status.remote_page_metadata_changed' }), + ), + unknownBlockCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: 'notion_md.status.unknown_block_count' }), + ), + }), +) + +export const pushSpanAttrs = OtelAttrs.defineSync( + Schema.Struct({ + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + force: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.push.force' })), + allowDeleteUnknownBlocks: Schema.Boolean.pipe( + OtelAttr.key({ key: 'notion_md.push.allow_delete_unknown_blocks' }), + ), + }), +) + +export const pushResultAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.page_id' })), + pushed: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.push.pushed' })), + }), +) + +export const syncResultAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.page_id' })), + result: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.sync.result' })), + }), +) + +export const stateFileAttrs = OtelAttrs.defineSync( + Schema.Struct({ + operation: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.state.operation' })), + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + }), +) + +export const objectHashAttrs = OtelAttrs.defineSync( + Schema.Struct({ + hashPrefix: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.object.hash_prefix' })), + }), +) + +export const objectRoleAttrs = OtelAttrs.defineSync( + Schema.Struct({ + role: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.object.role' })), + basename: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' }))), + hashPrefix: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: 'notion_md.object.hash_prefix' })), + ), + }), +) + +export const markdownUpdateAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.page_id' })), + type: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.markdown_update.type' })), + allowDeletingContent: Schema.Boolean.pipe( + OtelAttr.key({ key: 'notion_md.markdown_update.allow_deleting_content' }), + ), + contentUpdateCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: 'notion_md.markdown_update.content_update_count' }), + ), + }), +) + +export const metadataUpdateAttrs = OtelAttrs.defineSync( + Schema.Struct({ + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.page_id' })), + hasTitle: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.page_metadata.title' })), + hasIcon: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.page_metadata.icon' })), + hasCover: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.page_metadata.cover' })), + inTrash: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.page_metadata.in_trash' })), + isLocked: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.page_metadata.is_locked' })), + }), +) + +export const pushDecisionAttrs = OtelAttrs.defineSync( + Schema.Struct({ + decision: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.push.decision' })), + }), +) + +export const pushMarkdownCommandAttrs = OtelAttrs.defineSync( + Schema.Struct({ + markdownCommand: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.push.markdown_command' })), + }), +) + +export const pushDecisionMarkdownCommandAttrs = OtelAttrs.defineSync( + Schema.Struct({ + decision: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.push.decision' })), + markdownCommand: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.push.markdown_command' })), + }), +) + +export const withOperation = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + operation.with(attributes), + Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error)), + ) + +export const annotateAttrs = ( + attributes: OtelAttrs, + value: Schema.Schema.Type, +): Effect.Effect => + OtelSpan.annotate({ attributes, value }).pipe( + Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error)), + ) + +export const StatusPathSpan = OtelOperation.define({ + name: 'notion-md.status-path', + attributes: pathRecursiveAttrs, + label: ({ basename }) => basename, +}) + +export const PlanPathSpan = OtelOperation.define({ + name: 'notion-md.plan-path', + attributes: pathPlanAttrs, + label: ({ basename }) => basename, +}) + +export const SyncPathSpan = OtelOperation.define({ + name: 'notion-md.sync-path', + attributes: pathSyncAttrs, + label: ({ basename }) => basename, +}) + +export const stateFileSpan = (operation: string) => + OtelOperation.define({ + name: `notion-md.state.${operation}`, + attributes: stateFileAttrs, + label: ({ basename }) => basename, + }) + +export const ReadNmdStateSpan = OtelOperation.define({ + name: 'notion-md.state.read-nmd', + attributes: stateFileAttrs, + label: ({ basename }) => basename, +}) + +export const WriteObjectStateSpan = OtelOperation.define({ + name: 'notion-md.state.write-object', + attributes: objectRoleAttrs, + label: ({ role }) => role, +}) + +export const ReadObjectStateSpan = OtelOperation.define({ + name: 'notion-md.state.read-object', + attributes: objectRoleAttrs, + label: ({ role }) => role, +}) + +export const PullPageSpan = OtelOperation.define({ + name: 'notion-md.pull-page', + attributes: pagePathAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const EstablishSidecarSpan = OtelOperation.define({ + name: 'notion-md.establish-sidecar', + attributes: pageAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const StatusPageSpan = OtelOperation.define({ + name: 'notion-md.status-page', + attributes: pathAttrs, + label: ({ basename }) => basename, +}) + +export const PushPageSpan = OtelOperation.define({ + name: 'notion-md.push-page', + attributes: pushSpanAttrs, + label: ({ basename }) => basename, +}) + +export const SyncPageSpan = OtelOperation.define({ + name: 'notion-md.sync-page', + attributes: pathAttrs, + label: ({ basename }) => basename, +}) + +export const GatewayPullPageSpan = OtelOperation.define({ + name: 'notion-md.gateway.pull-page', + attributes: pageAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const GatewayUpdateMarkdownSpan = OtelOperation.define({ + name: 'notion-md.gateway.update-markdown', + attributes: markdownUpdateAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const GatewayUpdatePagePropertiesSpan = OtelOperation.define({ + name: 'notion-md.gateway.update-page-properties', + attributes: pageAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const GatewayUpdatePageMetadataSpan = OtelOperation.define({ + name: 'notion-md.gateway.update-page-metadata', + attributes: metadataUpdateAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const GatewayListChildPagesSpan = OtelOperation.define({ + name: 'notion-md.gateway.list-child-pages', + attributes: pageAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const GatewayCreatePageSpan = OtelOperation.define({ + name: 'notion-md.gateway.create-page', + attributes: parentPageAttrs, + label: ({ parentPageId }) => parentPageId.slice(0, 8), +}) + +export const GatewayMovePageSpan = OtelOperation.define({ + name: 'notion-md.gateway.move-page', + attributes: pageAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const GatewayArchivePageSpan = OtelOperation.define({ + name: 'notion-md.gateway.archive-page', + attributes: pageAttrs, + label: ({ pageId }) => pageId.slice(0, 8), +}) + +export const page = (pageId: string) => GatewayPullPageSpan.encodeSync({ pageId }) + +export const parentPage = (parentPageId: string) => + GatewayCreatePageSpan.encodeSync({ parentPageId }) + +export const path = (filePath: string) => SyncPageSpan.encodeSync({ basename: basename(filePath) }) + +export const pagePath = (input: { readonly pageId: string; readonly path: string }) => + PullPageSpan.encodeSync({ + pageId: input.pageId, + basename: basename(input.path), + }) diff --git a/packages/@overeng/notion-md/src/path.ts b/packages/@overeng/notion-md/src/path.ts index e8b415f0f..22948ebc8 100644 --- a/packages/@overeng/notion-md/src/path.ts +++ b/packages/@overeng/notion-md/src/path.ts @@ -7,6 +7,7 @@ import { Effect } from 'effect' import { statusMany, syncMany, type BatchResult } from './batch.ts' import { NmdCliError, type NmdError } from './errors.ts' import type { NotionMdGateway } from './model.ts' +import * as Observability from './observability.ts' import type { NmdStateStore } from './state-store.ts' import { statusPage, @@ -81,11 +82,9 @@ export const statusPath = ( } return yield* syncManyStatus(opts) }).pipe( - Effect.withSpan('notion-md.status-path', { - attributes: { - 'span.label': basename(opts.path), - 'notion_md.path.recursive': opts.recursive === true, - }, + Observability.withOperation(Observability.StatusPathSpan, { + basename: basename(opts.path), + recursive: opts.recursive === true, }), ) @@ -122,11 +121,9 @@ export const planPath = ( ...(opts.rootFile === undefined ? {} : { rootFile: opts.rootFile }), }) }).pipe( - Effect.withSpan('notion-md.plan-path', { - attributes: { - 'span.label': basename(opts.path), - 'notion_md.tree.from_remote': opts.fromRemote === true, - }, + Observability.withOperation(Observability.PlanPathSpan, { + basename: basename(opts.path), + fromRemote: opts.fromRemote === true, }), ) @@ -182,12 +179,10 @@ export const syncPath = ( return yield* syncPage({ path: opts.path, ...pushSafety(opts) }) }).pipe( - Effect.withSpan('notion-md.sync-path', { - attributes: { - 'span.label': basename(opts.path), - 'notion_md.path.recursive': opts.recursive === true, - 'notion_md.tree.from_remote': opts.fromRemote === true, - }, + Observability.withOperation(Observability.SyncPathSpan, { + basename: basename(opts.path), + recursive: opts.recursive === true, + fromRemote: opts.fromRemote === true, }), ) diff --git a/packages/@overeng/notion-md/src/state-store.ts b/packages/@overeng/notion-md/src/state-store.ts index cd5169027..40169efa3 100644 --- a/packages/@overeng/notion-md/src/state-store.ts +++ b/packages/@overeng/notion-md/src/state-store.ts @@ -19,6 +19,7 @@ import { import { NmdFileSystemError, NmdObjectStoreError } from './errors.ts' import { normalizeMarkdownLineEndings, sha256Digest } from './hash.ts' +import * as Observability from './observability.ts' const compareStrings = new Intl.Collator().compare @@ -291,12 +292,9 @@ export const NmdStateStoreLive = Layer.effect( message: `Failed to write ${opts.label} ${opts.path}`, }), ), - Effect.withSpan(`notion-md.state.${opts.operation}`, { - attributes: { - 'span.label': path.basename(opts.path), - 'notion_md.state.operation': opts.operation, - 'notion_md.path.basename': path.basename(opts.path), - }, + Observability.withOperation(Observability.stateFileSpan(opts.operation), { + operation: opts.operation, + basename: path.basename(opts.path), }), ) @@ -310,12 +308,9 @@ export const NmdStateStoreLive = Layer.effect( message: `Failed to read .nmd file ${opts.path}`, }), ), - Effect.withSpan('notion-md.state.read-nmd', { - attributes: { - 'span.label': path.basename(opts.path), - 'notion_md.state.operation': 'read_nmd', - 'notion_md.path.basename': path.basename(opts.path), - }, + Observability.withOperation(Observability.ReadNmdStateSpan, { + operation: 'read_nmd', + basename: path.basename(opts.path), }), ) @@ -334,15 +329,14 @@ export const NmdStateStoreLive = Layer.effect( content, label: '.notion-md object', }) - yield* Effect.annotateCurrentSpan('notion_md.object.hash_prefix', hash.slice(0, 18)) + yield* Observability.annotateAttrs(Observability.objectHashAttrs, { + hashPrefix: hash.slice(0, 18), + }) return makeNmdObjectRef({ role: opts.role, hash, content }) }).pipe( - Effect.withSpan('notion-md.state.write-object', { - attributes: { - 'span.label': opts.role, - 'notion_md.object.role': opts.role, - 'notion_md.path.basename': path.basename(opts.path), - }, + Observability.withOperation(Observability.WriteObjectStateSpan, { + role: opts.role, + basename: path.basename(opts.path), }), ) @@ -373,12 +367,9 @@ export const NmdStateStoreLive = Layer.effect( } return content }).pipe( - Effect.withSpan('notion-md.state.read-object', { - attributes: { - 'span.label': opts.object.role, - 'notion_md.object.role': opts.object.role, - 'notion_md.object.hash_prefix': opts.object.hash.slice(0, 18), - }, + Observability.withOperation(Observability.ReadObjectStateSpan, { + role: opts.object.role, + hashPrefix: opts.object.hash.slice(0, 18), }), ) diff --git a/packages/@overeng/notion-md/src/sync.ts b/packages/@overeng/notion-md/src/sync.ts index dac52a4bf..3771235b4 100644 --- a/packages/@overeng/notion-md/src/sync.ts +++ b/packages/@overeng/notion-md/src/sync.ts @@ -33,6 +33,7 @@ import { type WritablePageCover, type WritablePageIcon, } from './model.ts' +import * as Observability from './observability.ts' import { NmdStateStore, readBaseSnapshot, @@ -652,12 +653,9 @@ export const pullPage = ( baselineBody: pulled.markdown.markdown, }) }).pipe( - Effect.withSpan('notion-md.pull-page', { - attributes: { - 'span.label': opts.pageId.slice(0, 8), - 'notion_md.page_id': opts.pageId, - 'notion_md.path.basename': basename(opts.outPath), - }, + Observability.withOperation(Observability.PullPageSpan, { + pageId: opts.pageId, + basename: basename(opts.outPath), }), ) @@ -697,11 +695,7 @@ const establishSidecarFromRemote = (opts: { baselineBody, }), }) - }).pipe( - Effect.withSpan('notion-md.establish-sidecar', { - attributes: { 'span.label': opts.pageId.slice(0, 8), 'notion_md.page_id': opts.pageId }, - }), - ) + }).pipe(Observability.withOperation(Observability.EstablishSidecarSpan, { pageId: opts.pageId })) const readNmd = ( path: string, @@ -894,23 +888,18 @@ export const statusPage = ( return statusFromSnapshots({ path: opts.path, local, remote }) }).pipe( Effect.tap((status) => - Effect.annotateCurrentSpan({ - 'notion_md.page_id': status.pageId, - 'notion_md.status.local_changed': status.localChanged, - 'notion_md.status.local_page_metadata_changed': status.localPageMetadataChanged, - 'notion_md.status.local_properties_changed': status.localPropertiesChanged, - 'notion_md.status.remote_changed': status.remoteChanged, - 'notion_md.status.remote_body_changed': status.remoteBodyChanged, - 'notion_md.status.remote_page_metadata_changed': status.remotePageMetadataChanged, - 'notion_md.status.unknown_block_count': status.unresolvedUnknownBlocks.length, + Observability.annotateAttrs(Observability.statusAttrs, { + pageId: status.pageId, + localChanged: status.localChanged, + localPageMetadataChanged: status.localPageMetadataChanged, + localPropertiesChanged: status.localPropertiesChanged, + remoteChanged: status.remoteChanged, + remoteBodyChanged: status.remoteBodyChanged, + remotePageMetadataChanged: status.remotePageMetadataChanged, + unknownBlockCount: status.unresolvedUnknownBlocks.length, }), ), - Effect.withSpan('notion-md.status-page', { - attributes: { - 'span.label': basename(opts.path), - 'notion_md.path.basename': basename(opts.path), - }, - }), + Observability.withOperation(Observability.StatusPageSpan, { basename: basename(opts.path) }), ) /** @@ -1157,8 +1146,8 @@ export const pushGuarded = (opts: { status.localChanged === false && (status.localPageMetadataChanged === true || status.localPropertiesChanged === true) ) { - yield* Effect.annotateCurrentSpan({ - 'notion_md.push.decision': 'metadata_only_remote_body_changed', + yield* Observability.annotateAttrs(Observability.pushDecisionAttrs, { + decision: 'metadata_only_remote_body_changed', }) if (hasPageMetadataUpdate(metadataUpdate) === true) { yield* gateway.updatePageMetadata({ pageId: status.pageId, metadata: metadataUpdate }) @@ -1186,7 +1175,9 @@ export const pushGuarded = (opts: { } if (mergedBody !== undefined) { - yield* Effect.annotateCurrentSpan({ 'notion_md.push.decision': 'auto_merge' }) + yield* Observability.annotateAttrs(Observability.pushDecisionAttrs, { + decision: 'auto_merge', + }) const command = options.replaceContent === true ? ({ _tag: 'replace_content', markdown: mergedBody } as const) @@ -1195,7 +1186,9 @@ export const pushGuarded = (opts: { remoteBody: remoteForStatus.markdown.markdown, desiredBody: mergedBody, }) - yield* Effect.annotateCurrentSpan({ 'notion_md.push.markdown_command': command._tag }) + yield* Observability.annotateAttrs(Observability.pushMarkdownCommandAttrs, { + markdownCommand: command._tag, + }) yield* gateway.updateMarkdown({ pageId: status.pageId, command, @@ -1225,7 +1218,9 @@ export const pushGuarded = (opts: { localBody: local.desiredBody, remoteBody: remoteForStatus.markdown.markdown, }) - yield* Effect.annotateCurrentSpan({ 'notion_md.push.decision': 'body_conflict' }) + yield* Observability.annotateAttrs(Observability.pushDecisionAttrs, { + decision: 'body_conflict', + }) return yield* new NmdConflictError({ path, page_id: status.pageId, @@ -1279,9 +1274,9 @@ export const pushGuarded = (opts: { remoteBody: remote.markdown.markdown, desiredBody: local.desiredBody, }) - yield* Effect.annotateCurrentSpan({ - 'notion_md.push.decision': options.force === true ? 'force_replace' : 'guarded_update', - 'notion_md.push.markdown_command': command._tag, + yield* Observability.annotateAttrs(Observability.pushDecisionMarkdownCommandAttrs, { + decision: options.force === true ? 'force_replace' : 'guarded_update', + markdownCommand: command._tag, }) yield* gateway.updateMarkdown({ pageId: status.pageId, @@ -1337,18 +1332,15 @@ export const pushPageWithPolicy = ( }) }).pipe( Effect.tap((result) => - Effect.annotateCurrentSpan({ - 'notion_md.page_id': result.pageId, - 'notion_md.push.pushed': result.pushed, + Observability.annotateAttrs(Observability.pushResultAttrs, { + pageId: result.pageId, + pushed: result.pushed, }), ), - Effect.withSpan('notion-md.push-page', { - attributes: { - 'span.label': basename(opts.path), - 'notion_md.path.basename': basename(opts.path), - 'notion_md.push.force': opts.force === true, - 'notion_md.push.allow_delete_unknown_blocks': opts.allowDeletingUnknownBlocks === true, - }, + Observability.withOperation(Observability.PushPageSpan, { + basename: basename(opts.path), + force: opts.force === true, + allowDeleteUnknownBlocks: opts.allowDeletingUnknownBlocks === true, }), ) @@ -1399,15 +1391,10 @@ export const syncPage = ( } as const }).pipe( Effect.tap((result) => - Effect.annotateCurrentSpan({ - 'notion_md.page_id': result.pageId, - 'notion_md.sync.result': result._tag, + Observability.annotateAttrs(Observability.syncResultAttrs, { + pageId: result.pageId, + result: result._tag, }), ), - Effect.withSpan('notion-md.sync-page', { - attributes: { - 'span.label': basename(opts.path), - 'notion_md.path.basename': basename(opts.path), - }, - }), + Observability.withOperation(Observability.SyncPageSpan, { basename: basename(opts.path) }), ) diff --git a/packages/@overeng/notion-md/src/tree.ts b/packages/@overeng/notion-md/src/tree.ts index befe5b674..19fd0fcc7 100644 --- a/packages/@overeng/notion-md/src/tree.ts +++ b/packages/@overeng/notion-md/src/tree.ts @@ -1,7 +1,7 @@ import { basename, dirname, extname, join, relative, resolve } from 'node:path' import { FileSystem } from '@effect/platform' -import { Effect } from 'effect' +import { Effect, Schema } from 'effect' import { NOTION_API_VERSION, @@ -9,6 +9,7 @@ import { type NmdParentRef, } from '@overeng/notion-effect-client' import { parseNotionUuid } from '@overeng/notion-effect-schema' +import { OtelAttr, OtelOperation } from '@overeng/otel-contract' import { titleSlug } from '@overeng/utils' import { pageUrl, resolveCrossRefs, validateCrossRefTargets } from './cross-refs.ts' @@ -21,6 +22,7 @@ import { import { parseNmdFile, renderNmdFile } from './frontmatter.ts' import { normalizeMarkdownLineEndings, sha256Digest } from './hash.ts' import { NotionMdGateway, type RemoteMarkdownSnapshot, type RemotePageSnapshot } from './model.ts' +import { withOperation } from './observability.ts' import { NmdStateStore, readSyncStateOptional, @@ -73,6 +75,16 @@ const NMD_EXT = '.nmd' /** Default root-file candidates in priority order, when not explicitly given. */ const ROOT_FILE_CANDIDATES = ['index.nmd', 'README.nmd'] as const +const SyncTreeSpan = OtelOperation.define({ + name: 'notion-md.sync-tree', + schema: Schema.Struct({ + basename: Schema.String.pipe(OtelAttr.key({ key: 'notion_md.path.basename' })), + plan: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.tree.plan' })), + fromRemote: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion_md.tree.from_remote' })), + }), + label: ({ basename }) => basename, +}) + /** * Sentinel "file path" inside the tree root used for all `.notion-md/` state * operations. The state-store derives the `.notion-md/` directory by stripping @@ -1340,11 +1352,9 @@ export const syncTree = (opts: { }) return { _tag: 'tree', root, rootPageId, rootFile, direction: 'local', plan, ops } as const }).pipe( - Effect.withSpan('notion-md.sync-tree', { - attributes: { - 'span.label': basename(opts.root), - 'notion_md.tree.plan': opts.plan === true, - 'notion_md.tree.from_remote': opts.fromRemote === true, - }, + withOperation(SyncTreeSpan, { + basename: basename(opts.root), + plan: opts.plan === true, + fromRemote: opts.fromRemote === true, }), ) diff --git a/packages/@overeng/notion-md/tsconfig.json b/packages/@overeng/notion-md/tsconfig.json index f0653f275..485854850 100644 --- a/packages/@overeng/notion-md/tsconfig.json +++ b/packages/@overeng/notion-md/tsconfig.json @@ -57,6 +57,9 @@ { "path": "../notion-effect-schema" }, + { + "path": "../otel-contract" + }, { "path": "../tui-react" }, diff --git a/packages/@overeng/notion-md/tsconfig.json.genie.ts b/packages/@overeng/notion-md/tsconfig.json.genie.ts index c241a55d8..787a510c6 100644 --- a/packages/@overeng/notion-md/tsconfig.json.genie.ts +++ b/packages/@overeng/notion-md/tsconfig.json.genie.ts @@ -18,6 +18,7 @@ export default tsconfigJson({ { path: '../notion-core' }, { path: '../notion-effect-client' }, { path: '../notion-effect-schema' }, + { path: '../otel-contract' }, { path: '../tui-react' }, { path: '../utils' }, { path: '../utils-dev' }, diff --git a/packages/@overeng/notion-react/package.json b/packages/@overeng/notion-react/package.json index 5fb6ffa1b..2aad9effe 100644 --- a/packages/@overeng/notion-react/package.json +++ b/packages/@overeng/notion-react/package.json @@ -44,6 +44,7 @@ "@effect/workflow": "0.18.0", "@overeng/notion-effect-client": "workspace:^", "@overeng/notion-effect-schema": "workspace:^", + "@overeng/otel-contract": "workspace:^", "@playwright/test": "1.59.1", "effect": "3.21.2", "vitest": "3.2.4" @@ -99,6 +100,7 @@ "packages/@overeng/notion-effect-client", "packages/@overeng/notion-effect-schema", "packages/@overeng/notion-react", + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/notion-react/package.json.genie.ts b/packages/@overeng/notion-react/package.json.genie.ts index 67be109e2..a1dbfc6ee 100644 --- a/packages/@overeng/notion-react/package.json.genie.ts +++ b/packages/@overeng/notion-react/package.json.genie.ts @@ -7,6 +7,7 @@ import { } from '../../../genie/internal.ts' import notionEffectClientPkg from '../notion-effect-client/package.json.genie.ts' import notionEffectSchemaPkg from '../notion-effect-schema/package.json.genie.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' import utilsPkg from '../utils/package.json.genie.ts' @@ -16,7 +17,7 @@ const optionalPeerDepNames = ['@opentelemetry/api', 'katex', 'shiki'] as const const workspaceDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/notion-react' }), dependencies: { - workspace: [notionEffectClientPkg, notionEffectSchemaPkg], + workspace: [notionEffectClientPkg, notionEffectSchemaPkg, otelContractPkg], external: catalog.pick('@effect/platform'), }, devDependencies: { diff --git a/packages/@overeng/notion-react/src/o11y/effect-adapter.ts b/packages/@overeng/notion-react/src/o11y/effect-adapter.ts index 71a6247b2..b5cdccc63 100644 --- a/packages/@overeng/notion-react/src/o11y/effect-adapter.ts +++ b/packages/@overeng/notion-react/src/o11y/effect-adapter.ts @@ -18,11 +18,12 @@ import type { HttpClient } from '@effect/platform' * - Span correlation uses `OpIssued.id` as a Map key so out-of-order * terminal events still resolve correctly. */ -import { Cause, Context, Effect, Exit, Option } from 'effect' +import { Cause, Context, Effect, Exit, Option, Schema } from 'effect' import type { Tracer } from 'effect' import type { ReactNode } from 'react' import type { NotionConfig } from '@overeng/notion-effect-client' +import { OtelAttr, OtelAttrs } from '@overeng/otel-contract' import type { NotionCache } from '../cache/types.ts' import type { NotionSyncError } from '../renderer/errors.ts' @@ -40,6 +41,99 @@ const shortId = (id: string): string => id.replaceAll('-', '').slice(0, 8) /** Milliseconds → nanoseconds (bigint) for Tracer.Span APIs. */ const msToNs = (ms: number): bigint => BigInt(Math.trunc(ms * 1_000_000)) +const OpKind = Schema.Literal('append', 'update', 'delete', 'retrieve') +const CacheKind = Schema.Literal('hit', 'miss', 'drift', 'page-id-drift') +const SyncFallbackReasonSchema = Schema.String + +const SyncStartAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion-react.page_id' })), + rootBlockCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: 'notion-react.root_block_count' }), + ), + }), +) + +const SyncEndAttrs = OtelAttrs.defineSync( + Schema.Struct({ + ok: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion-react.ok' })), + opCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.op_count' })), + durationMs: Schema.Number.pipe(OtelAttr.key({ key: 'notion-react.duration_ms' })), + fallbackReason: Schema.optional( + SyncFallbackReasonSchema.pipe(OtelAttr.key({ key: 'notion-react.fallback_reason' })), + ), + }), +) + +const OpStartAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + label: OpKind.pipe(OtelAttr.spanLabel()), + id: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.op.id' })), + kind: OpKind.pipe(OtelAttr.key({ key: 'notion-react.op.kind' })), + }), +) + +const OpSucceededAttrs = OtelAttrs.defineSync( + Schema.Struct({ + durationMs: Schema.Number.pipe(OtelAttr.key({ key: 'notion-react.op.duration_ms' })), + resultCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.op.result_count' })), + note: Schema.optional( + Schema.Literal('already-archived').pipe(OtelAttr.key({ key: 'notion-react.op.note' })), + ), + }), +) + +const OpFailedAttrs = OtelAttrs.defineSync( + Schema.Struct({ + durationMs: Schema.Number.pipe(OtelAttr.key({ key: 'notion-react.op.duration_ms' })), + error: Schema.String.pipe(OtelAttr.key({ key: 'notion-react.op.error' })), + }), +) + +const CacheEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + }), +) + +const FallbackEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + reason: SyncFallbackReasonSchema.pipe(OtelAttr.key({ key: 'notion-react.fallback_reason' })), + }), +) + +const BatchFlushEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + issued: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.batch.issued' })), + batched: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.batch.batched' })), + }), +) + +const UpdateNoopEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + blockId: Schema.String.pipe(OtelAttr.key({ key: 'notion-react.block_id' })), + reason: Schema.Literal('hash-equal', 'other').pipe( + OtelAttr.key({ key: 'notion-react.noop_reason' }), + ), + }), +) + +const CheckpointWrittenEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + bytes: Schema.optional( + Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.checkpoint.bytes' })), + ), + }), +) + /** Build a successful / failed Exit for Span.end. */ const successExit: Exit.Exit = Exit.void const failureExit: Exit.Exit = Exit.fail('notion-react.sync.failed') @@ -78,10 +172,8 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven let rootSpan: Tracer.Span | undefined const opSpans = new Map() - const attrs = (extra: Record): Record => ({ - 'service.name': serviceName, - ...extra, - }) + const cacheEventAttrs = (kind: typeof CacheKind.Type) => + CacheEventAttrs.encodeSync({ serviceName, label: `cache:${kind}` }) return (event: SyncEvent): void => { switch (event._tag) { @@ -95,10 +187,11 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven 'internal', ) for (const [k, v] of Object.entries( - attrs({ - 'span.label': shortId(event.pageId), - 'notion-react.page_id': event.pageId, - 'notion-react.root_block_count': event.rootBlockCount, + SyncStartAttrs.encodeSync({ + serviceName, + label: shortId(event.pageId), + pageId: event.pageId, + rootBlockCount: event.rootBlockCount, }), )) { rootSpan.attribute(k, v) @@ -107,11 +200,15 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven } case 'SyncEnd': { if (rootSpan === undefined) break - rootSpan.attribute('notion-react.ok', event.ok) - rootSpan.attribute('notion-react.op_count', event.opCount) - rootSpan.attribute('notion-react.duration_ms', event.durationMs) - if (event.fallbackReason !== undefined) { - rootSpan.attribute('notion-react.fallback_reason', event.fallbackReason) + for (const [k, v] of Object.entries( + SyncEndAttrs.encodeSync({ + ok: event.ok, + opCount: event.opCount, + durationMs: event.durationMs, + ...(event.fallbackReason === undefined ? {} : { fallbackReason: event.fallbackReason }), + }), + )) { + rootSpan.attribute(k, v) } rootSpan.end(msToNs(event.at), event.ok ? successExit : failureExit) rootSpan = undefined @@ -134,10 +231,11 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven 'internal', ) for (const [k, v] of Object.entries( - attrs({ - 'span.label': event.kind, - 'notion-react.op.id': event.id, - 'notion-react.op.kind': event.kind, + OpStartAttrs.encodeSync({ + serviceName, + label: event.kind, + id: event.id, + kind: event.kind, }), )) { span.attribute(k, v) @@ -148,10 +246,14 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven case 'OpSucceeded': { const open = opSpans.get(event.id) if (open === undefined) break - open.span.attribute('notion-react.op.duration_ms', event.durationMs) - open.span.attribute('notion-react.op.result_count', event.resultCount) - if (event.note !== undefined) { - open.span.attribute('notion-react.op.note', event.note) + for (const [k, v] of Object.entries( + OpSucceededAttrs.encodeSync({ + durationMs: event.durationMs, + resultCount: event.resultCount, + ...(event.note === undefined ? {} : { note: event.note }), + }), + )) { + open.span.attribute(k, v) } open.span.end(msToNs(event.at), successExit) opSpans.delete(event.id) @@ -160,19 +262,18 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven case 'OpFailed': { const open = opSpans.get(event.id) if (open === undefined) break - open.span.attribute('notion-react.op.duration_ms', event.durationMs) - open.span.attribute('notion-react.op.error', event.error) + for (const [k, v] of Object.entries( + OpFailedAttrs.encodeSync({ durationMs: event.durationMs, error: event.error }), + )) { + open.span.attribute(k, v) + } open.span.end(msToNs(event.at), Exit.fail(event.error)) opSpans.delete(event.id) break } case 'CacheOutcome': { if (rootSpan === undefined) break - rootSpan.event( - `cache:${event.kind}`, - msToNs(event.at), - attrs({ 'span.label': `cache:${event.kind}` }), - ) + rootSpan.event(`cache:${event.kind}`, msToNs(event.at), cacheEventAttrs(event.kind)) break } case 'FallbackTriggered': { @@ -180,7 +281,7 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven rootSpan.event( 'fallback', msToNs(event.at), - attrs({ 'notion-react.fallback_reason': event.reason }), + FallbackEventAttrs.encodeSync({ serviceName, reason: event.reason }), ) break } @@ -189,9 +290,10 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven rootSpan.event( 'batch-flush', msToNs(event.at), - attrs({ - 'notion-react.batch.issued': event.issued, - 'notion-react.batch.batched': event.batched, + BatchFlushEventAttrs.encodeSync({ + serviceName, + issued: event.issued, + batched: event.batched, }), ) break @@ -201,9 +303,10 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven rootSpan.event( 'update-noop', msToNs(event.at), - attrs({ - 'notion-react.block_id': event.blockId, - 'notion-react.noop_reason': event.reason, + UpdateNoopEventAttrs.encodeSync({ + serviceName, + blockId: event.blockId, + reason: event.reason, }), ) break @@ -213,7 +316,10 @@ export const makeEffectSpanHandler = (config: EffectSpanHandlerConfig): SyncEven rootSpan.event( 'checkpoint-written', msToNs(event.at), - attrs(event.bytes !== undefined ? { 'notion-react.checkpoint.bytes': event.bytes } : {}), + CheckpointWrittenEventAttrs.encodeSync({ + serviceName, + ...(event.bytes === undefined ? {} : { bytes: event.bytes }), + }), ) break } diff --git a/packages/@overeng/notion-react/src/o11y/otel-adapter.ts b/packages/@overeng/notion-react/src/o11y/otel-adapter.ts index acbd31865..5b88c6e61 100644 --- a/packages/@overeng/notion-react/src/o11y/otel-adapter.ts +++ b/packages/@overeng/notion-react/src/o11y/otel-adapter.ts @@ -12,6 +12,9 @@ */ import { context, trace } from '@opentelemetry/api' import type { Attributes, Span, SpanStatusCode, Tracer } from '@opentelemetry/api' +import { Schema } from 'effect' + +import { OtelAttr, OtelAttrs } from '@overeng/otel-contract' import type { SyncEvent, SyncEventHandler } from '../renderer/sync-events.ts' @@ -24,6 +27,98 @@ const shortId = (id: string): string => id.replaceAll('-', '').slice(0, 8) const STATUS_OK = 1 as SpanStatusCode const STATUS_ERROR = 2 as SpanStatusCode +const OpKind = Schema.Literal('append', 'update', 'delete', 'retrieve') +const SyncFallbackReasonSchema = Schema.String + +const SyncStartAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + pageId: Schema.String.pipe(OtelAttr.key({ key: 'notion-react.page_id' })), + rootBlockCount: Schema.NonNegativeInt.pipe( + OtelAttr.key({ key: 'notion-react.root_block_count' }), + ), + }), +) + +const SyncEndAttrs = OtelAttrs.defineSync( + Schema.Struct({ + ok: Schema.Boolean.pipe(OtelAttr.key({ key: 'notion-react.ok' })), + opCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.op_count' })), + durationMs: Schema.Number.pipe(OtelAttr.key({ key: 'notion-react.duration_ms' })), + fallbackReason: Schema.optional( + SyncFallbackReasonSchema.pipe(OtelAttr.key({ key: 'notion-react.fallback_reason' })), + ), + }), +) + +const OpStartAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + label: OpKind.pipe(OtelAttr.spanLabel()), + id: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.op.id' })), + kind: OpKind.pipe(OtelAttr.key({ key: 'notion-react.op.kind' })), + }), +) + +const OpSucceededAttrs = OtelAttrs.defineSync( + Schema.Struct({ + durationMs: Schema.Number.pipe(OtelAttr.key({ key: 'notion-react.op.duration_ms' })), + resultCount: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.op.result_count' })), + note: Schema.optional( + Schema.Literal('already-archived').pipe(OtelAttr.key({ key: 'notion-react.op.note' })), + ), + }), +) + +const OpFailedAttrs = OtelAttrs.defineSync( + Schema.Struct({ + durationMs: Schema.Number.pipe(OtelAttr.key({ key: 'notion-react.op.duration_ms' })), + error: Schema.String.pipe(OtelAttr.key({ key: 'notion-react.op.error' })), + }), +) + +const CacheEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + label: Schema.NonEmptyString.pipe(OtelAttr.spanLabel()), + }), +) + +const FallbackEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + reason: SyncFallbackReasonSchema.pipe(OtelAttr.key({ key: 'notion-react.fallback_reason' })), + }), +) + +const BatchFlushEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + issued: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.batch.issued' })), + batched: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.batch.batched' })), + }), +) + +const UpdateNoopEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + blockId: Schema.String.pipe(OtelAttr.key({ key: 'notion-react.block_id' })), + reason: Schema.Literal('hash-equal', 'other').pipe( + OtelAttr.key({ key: 'notion-react.noop_reason' }), + ), + }), +) + +const CheckpointWrittenEventAttrs = OtelAttrs.defineSync( + Schema.Struct({ + serviceName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'service.name' })), + bytes: Schema.optional( + Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'notion-react.checkpoint.bytes' })), + ), + }), +) + /** Config for {@link createOtelEventHandler}. */ export interface OtelEventHandlerConfig { readonly tracer: Tracer @@ -50,31 +145,33 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven let rootSpan: Span | undefined const opSpans = new Map() - const withService = (extra: Attributes): Attributes => ({ 'service.name': serviceName, ...extra }) + const cacheEventAttrs = (kind: 'hit' | 'miss' | 'drift' | 'page-id-drift'): Attributes => + CacheEventAttrs.encodeSync({ serviceName, label: `cache:${kind}` }) return (event: SyncEvent): void => { switch (event._tag) { case 'SyncStart': { rootSpan = tracer.startSpan('notion-react.sync', { startTime: event.at, - attributes: withService({ - 'span.label': shortId(event.pageId), - 'notion-react.page_id': event.pageId, - 'notion-react.root_block_count': event.rootBlockCount, + attributes: SyncStartAttrs.encodeSync({ + serviceName, + label: shortId(event.pageId), + pageId: event.pageId, + rootBlockCount: event.rootBlockCount, }), }) break } case 'SyncEnd': { if (rootSpan === undefined) break - rootSpan.setAttributes({ - 'notion-react.ok': event.ok, - 'notion-react.op_count': event.opCount, - 'notion-react.duration_ms': event.durationMs, - ...(event.fallbackReason !== undefined - ? { 'notion-react.fallback_reason': event.fallbackReason } - : {}), - }) + rootSpan.setAttributes( + SyncEndAttrs.encodeSync({ + ok: event.ok, + opCount: event.opCount, + durationMs: event.durationMs, + ...(event.fallbackReason === undefined ? {} : { fallbackReason: event.fallbackReason }), + }), + ) rootSpan.setStatus({ code: event.ok ? STATUS_OK : STATUS_ERROR }) rootSpan.end(event.at) rootSpan = undefined @@ -95,10 +192,11 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven `notion-react.op.${event.kind}`, { startTime: event.at, - attributes: withService({ - 'span.label': event.kind, - 'notion-react.op.id': event.id, - 'notion-react.op.kind': event.kind, + attributes: OpStartAttrs.encodeSync({ + serviceName, + label: event.kind, + id: event.id, + kind: event.kind, }), }, parentCtx, @@ -109,11 +207,13 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven case 'OpSucceeded': { const span = opSpans.get(event.id) if (span === undefined) break - span.setAttributes({ - 'notion-react.op.duration_ms': event.durationMs, - 'notion-react.op.result_count': event.resultCount, - ...(event.note !== undefined ? { 'notion-react.op.note': event.note } : {}), - }) + span.setAttributes( + OpSucceededAttrs.encodeSync({ + durationMs: event.durationMs, + resultCount: event.resultCount, + ...(event.note === undefined ? {} : { note: event.note }), + }), + ) span.setStatus({ code: STATUS_OK }) span.end(event.at) opSpans.delete(event.id) @@ -122,10 +222,9 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven case 'OpFailed': { const span = opSpans.get(event.id) if (span === undefined) break - span.setAttributes({ - 'notion-react.op.duration_ms': event.durationMs, - 'notion-react.op.error': event.error, - }) + span.setAttributes( + OpFailedAttrs.encodeSync({ durationMs: event.durationMs, error: event.error }), + ) span.setStatus({ code: STATUS_ERROR, message: event.error }) span.end(event.at) opSpans.delete(event.id) @@ -133,18 +232,14 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven } case 'CacheOutcome': { if (rootSpan === undefined) break - rootSpan.addEvent( - `cache:${event.kind}`, - withService({ 'span.label': `cache:${event.kind}` }), - event.at, - ) + rootSpan.addEvent(`cache:${event.kind}`, cacheEventAttrs(event.kind), event.at) break } case 'FallbackTriggered': { if (rootSpan === undefined) break rootSpan.addEvent( 'fallback', - withService({ 'notion-react.fallback_reason': event.reason }), + FallbackEventAttrs.encodeSync({ serviceName, reason: event.reason }), event.at, ) break @@ -153,9 +248,10 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven if (rootSpan === undefined) break rootSpan.addEvent( 'batch-flush', - withService({ - 'notion-react.batch.issued': event.issued, - 'notion-react.batch.batched': event.batched, + BatchFlushEventAttrs.encodeSync({ + serviceName, + issued: event.issued, + batched: event.batched, }), event.at, ) @@ -165,9 +261,10 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven if (rootSpan === undefined) break rootSpan.addEvent( 'update-noop', - withService({ - 'notion-react.block_id': event.blockId, - 'notion-react.noop_reason': event.reason, + UpdateNoopEventAttrs.encodeSync({ + serviceName, + blockId: event.blockId, + reason: event.reason, }), event.at, ) @@ -177,9 +274,10 @@ export const createOtelEventHandler = (config: OtelEventHandlerConfig): SyncEven if (rootSpan === undefined) break rootSpan.addEvent( 'checkpoint-written', - withService( - event.bytes !== undefined ? { 'notion-react.checkpoint.bytes': event.bytes } : {}, - ), + CheckpointWrittenEventAttrs.encodeSync({ + serviceName, + ...(event.bytes === undefined ? {} : { bytes: event.bytes }), + }), event.at, ) break diff --git a/packages/@overeng/notion-react/tsconfig.json b/packages/@overeng/notion-react/tsconfig.json index 3241dc1e0..ab0c8626c 100644 --- a/packages/@overeng/notion-react/tsconfig.json +++ b/packages/@overeng/notion-react/tsconfig.json @@ -52,6 +52,9 @@ { "path": "../notion-effect-schema" }, + { + "path": "../otel-contract" + }, { "path": "../utils" }, diff --git a/packages/@overeng/notion-react/tsconfig.json.genie.ts b/packages/@overeng/notion-react/tsconfig.json.genie.ts index 522f3cf66..df84fd7f8 100644 --- a/packages/@overeng/notion-react/tsconfig.json.genie.ts +++ b/packages/@overeng/notion-react/tsconfig.json.genie.ts @@ -18,6 +18,7 @@ export default tsconfigJson({ references: [ { path: '../notion-effect-client' }, { path: '../notion-effect-schema' }, + { path: '../otel-contract' }, { path: '../utils' }, { path: '../utils-dev' }, ], diff --git a/packages/@overeng/otel-contract/package.json b/packages/@overeng/otel-contract/package.json new file mode 100644 index 000000000..85d9860a8 --- /dev/null +++ b/packages/@overeng/otel-contract/package.json @@ -0,0 +1,33 @@ +{ + "name": "@overeng/otel-contract", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/mod.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/mod.js" + } + }, + "devDependencies": { + "@overeng/utils-dev": "workspace:^", + "@types/node": "25.3.3", + "effect": "3.21.2", + "typescript": "5.9.3", + "vitest": "3.2.4" + }, + "peerDependencies": { + "effect": "^3.21.2" + }, + "$genie": { + "source": "package.json.genie.ts", + "warning": "DO NOT EDIT - changes will be overwritten", + "workspaceClosureDirs": [ + "packages/@overeng/otel-contract", + "packages/@overeng/utils-dev" + ] + } +} diff --git a/packages/@overeng/otel-contract/package.json.genie.ts b/packages/@overeng/otel-contract/package.json.genie.ts new file mode 100644 index 000000000..7a0872cc5 --- /dev/null +++ b/packages/@overeng/otel-contract/package.json.genie.ts @@ -0,0 +1,38 @@ +import { + catalog, + packageJson, + privatePackageDefaults, + workspaceMember, + type PackageJsonData, +} from '../../../genie/internal.ts' +import utilsDevPkg from '../utils-dev/package.json.genie.ts' + +const peerDepNames = ['effect'] as const + +const workspaceDeps = catalog.compose({ + workspace: workspaceMember({ memberPath: 'packages/@overeng/otel-contract' }), + devDependencies: { + workspace: [utilsDevPkg], + external: catalog.pick(...peerDepNames, '@types/node', 'typescript', 'vitest'), + }, + peerDependencies: { + external: catalog.pick(...peerDepNames), + }, +}) + +export default packageJson( + { + name: '@overeng/otel-contract', + ...privatePackageDefaults, + exports: { + '.': './src/mod.ts', + }, + publishConfig: { + access: 'public', + exports: { + '.': './dist/mod.js', + }, + }, + } satisfies PackageJsonData, + workspaceDeps, +) diff --git a/packages/@overeng/otel-contract/src/mod.ts b/packages/@overeng/otel-contract/src/mod.ts new file mode 100644 index 000000000..d645e53e3 --- /dev/null +++ b/packages/@overeng/otel-contract/src/mod.ts @@ -0,0 +1,1444 @@ +import { + Cause, + DateTime, + Duration, + Effect, + Either, + Exit, + Metric, + MetricBoundaries, + Option, + Redacted, + Schema, + Stream, +} from 'effect' +import * as AST from 'effect/SchemaAST' + +type OtelPrimitive = string | number | boolean + +export const OtelAttributeKey = Schema.NonEmptyTrimmedString.pipe( + Schema.maxLength(255), + Schema.pattern(/^[A-Za-z][A-Za-z0-9_.:-]*$/), + Schema.brand('OtelAttributeKey'), + Schema.annotations({ identifier: 'Otel.AttributeKey' }), +) +export type OtelAttributeKey = typeof OtelAttributeKey.Type + +export const OtelSpanName = Schema.NonEmptyTrimmedString.pipe( + Schema.maxLength(255), + Schema.pattern(/^[ -~]+$/), + Schema.brand('OtelSpanName'), + Schema.annotations({ identifier: 'Otel.SpanName' }), +) +export type OtelSpanName = typeof OtelSpanName.Type + +export const OtelMetricName = Schema.NonEmptyTrimmedString.pipe( + Schema.maxLength(255), + Schema.pattern(/^[A-Za-z_:][A-Za-z0-9_.:-]*$/), + Schema.brand('OtelMetricName'), + Schema.annotations({ identifier: 'Otel.MetricName' }), +) +export type OtelMetricName = typeof OtelMetricName.Type + +export const OtelServiceName = Schema.NonEmptyTrimmedString.pipe( + Schema.maxLength(255), + Schema.pattern(/^[A-Za-z][A-Za-z0-9_.:-]*$/), + Schema.brand('OtelServiceName'), + Schema.annotations({ identifier: 'Otel.ServiceName' }), +) +export type OtelServiceName = typeof OtelServiceName.Type + +/** Attribute value shape accepted by Effect's span annotation API and otelite flat rows. */ +export type OtelAttributeValue = OtelPrimitive + +/** Encoded OTEL attributes ready to pass to `Effect.withSpan` or `Effect.annotateCurrentSpan`. */ +export type OtelAttributeMap = Readonly> + +/** Explicit encoding policy for fields that cannot be safely derived from Schema AST alone. */ +export type OtelAttrEncodePolicy = + | 'auto' + | 'string' + | 'number' + | 'boolean' + | 'json' + | 'drop' + | 'redacted' + +/** OTEL-specific metadata attached to an Effect Schema node. */ +export interface OtelAttrMetadata { + readonly key?: string + readonly role?: 'span.label' + readonly encode?: OtelAttrEncodePolicy + readonly cardinality?: 'low' | 'bounded' | 'high' +} + +/** Private annotation key used to attach OTEL metadata to Effect schemas. */ +export const OtelAttrAnnotationId: unique symbol = Symbol.for('@overeng/utils/otel/Attr') + +/** Raised when `OtelAttrs.define` cannot derive a safe field plan from a schema. */ +export class OtelAttrPlanError extends Schema.TaggedError()( + 'OtelAttrPlanError', + { + path: Schema.Array(Schema.String), + message: Schema.String, + }, +) {} + +/** Raised when a value cannot be encoded as an OTEL attribute. */ +export class OtelAttrEncodeError extends Schema.TaggedError()( + 'OtelAttrEncodeError', + { + key: Schema.String, + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +type FieldEncoder = ( + value: unknown, +) => Effect.Effect + +const decodeNameSync = (options: { + readonly schema: Schema.Schema + readonly value: string + readonly path: ReadonlyArray + readonly kind: string +}): A => { + const decoded = decodeNameEither(options) + if (Either.isRight(decoded) === true) return decoded.right + throw decoded.left +} + +const decodeNameEither = (options: { + readonly schema: Schema.Schema + readonly value: string + readonly path: ReadonlyArray + readonly kind: string +}): Either.Either => + Schema.decodeUnknownEither(options.schema)(options.value).pipe( + Either.mapLeft(() => + unsupported({ + path: options.path, + message: `Invalid OTEL ${options.kind}: ${options.value}`, + }), + ), + ) + +const decodeAttributeKey = (key: string): Effect.Effect => + effectFromEither( + decodeNameEither({ + schema: OtelAttributeKey, + value: key, + path: ['attribute.key'], + kind: 'attribute key', + }), + ) + +const decodeSpanNameSync = (name: string): string => + decodeNameSync({ schema: OtelSpanName, value: name, path: ['span.name'], kind: 'span name' }) + +const decodeMetricNameSync = (name: string): string => + decodeNameSync({ + schema: OtelMetricName, + value: name, + path: ['metric.name'], + kind: 'metric name', + }) + +/** Stable metadata for one compiled schema field. */ +export interface OtelAttrFieldMetadata { + readonly sourceKey: string + readonly attrKey: string + readonly role?: OtelAttrMetadata['role'] + readonly optional: boolean + readonly encodePolicy: OtelAttrEncodePolicy + readonly cardinality?: NonNullable + readonly schemaIdentifier?: string + readonly astTag: string +} + +interface FieldPlan { + readonly sourceKey: PropertyKey + readonly attrKey: string + readonly role?: OtelAttrMetadata['role'] + readonly optional: boolean + readonly encodePolicy: OtelAttrEncodePolicy + readonly cardinality?: NonNullable + readonly schemaIdentifier?: string + readonly astTag: string + readonly encode: FieldEncoder +} + +/** Compiled schema-backed OTEL attribute contract. */ +export interface OtelAttrs { + readonly schema: S + readonly keys: ReadonlySet + readonly fields: ReadonlyArray + readonly hasSpanLabel: boolean + readonly encode: ( + value: Schema.Schema.Type, + ) => Effect.Effect + readonly encodeSync: (value: Schema.Schema.Type) => OtelAttributeMap + readonly unsafeEncode: (value: Schema.Schema.Type) => OtelAttributeMap +} + +/** Named span contract coupled to a compiled attribute schema. */ +export interface OtelSpanDefinition { + readonly name: string + readonly attributes: OtelAttrs + readonly root?: boolean + readonly metadata: OtelSpanMetadata +} + +/** Stable metadata for compiled span contracts. */ +export interface OtelSpanMetadata { + readonly kind: 'span' + readonly name: string + readonly root: boolean + readonly attributes: ReadonlyArray + readonly attributeKeys: ReadonlyArray + readonly hasSpanLabel: boolean +} + +/** Stable metadata for compiled operation contracts. */ +export interface OtelOperationMetadata { + readonly kind: 'operation' + readonly name: string + readonly root: boolean + readonly attributes: ReadonlyArray + readonly attributeKeys: ReadonlyArray + readonly derivesSpanLabel: boolean +} + +/** Named operation contract: the normal schema-first API for product code. */ +export interface OtelOperationDefinition { + readonly name: string + readonly attributes: OtelAttrs + readonly root?: boolean + readonly metadata: OtelOperationMetadata + readonly encode: ( + value: Schema.Schema.Type, + ) => Effect.Effect + readonly encodeSync: (value: Schema.Schema.Type) => OtelAttributeMap + readonly unsafeEncode: (value: Schema.Schema.Type) => OtelAttributeMap + readonly with: { + (options: { + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect + }): Effect.Effect + ( + attributes: Schema.Schema.Type, + ): (effect: Effect.Effect) => Effect.Effect + } + readonly withRoot: { + (options: { + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect + }): Effect.Effect + ( + attributes: Schema.Schema.Type, + ): (effect: Effect.Effect) => Effect.Effect + } + readonly withStream: { + (options: { + readonly attributes: Schema.Schema.Type + readonly stream: Stream.Stream + }): Stream.Stream + ( + attributes: Schema.Schema.Type, + ): (stream: Stream.Stream) => Stream.Stream + } + readonly annotate: (attributes: Schema.Schema.Type) => Effect.Effect +} + +/** Stable metadata for a schema-backed metric label contract. */ +export interface OtelMetricLabelsMetadata { + readonly kind: 'metric.labels' + readonly labels: ReadonlyArray + readonly labelKeys: ReadonlyArray +} + +/** Schema-backed metric labels. Metric labels intentionally use stricter cardinality policy than spans. */ +export interface OtelMetricLabels { + readonly schema: S + readonly attributes: OtelAttrs + readonly metadata: OtelMetricLabelsMetadata + readonly encode: ( + value: Schema.Schema.Type, + ) => Effect.Effect + readonly encodeSync: (value: Schema.Schema.Type) => OtelAttributeMap + readonly unsafeEncode: (value: Schema.Schema.Type) => OtelAttributeMap +} + +export type OtelMetricInstrumentKind = 'counter' | 'histogram' + +/** Stable metadata for schema-backed metric definitions. */ +export interface OtelMetricMetadata { + readonly kind: 'metric' + readonly instrument: OtelMetricInstrumentKind + readonly name: string + readonly description?: string + readonly unit?: string + readonly labels: ReadonlyArray + readonly labelKeys: ReadonlyArray + readonly boundaries?: ReadonlyArray +} + +/** Runtime-light metric contract. It owns names, labels, cardinality, and metadata, not emission. */ +export interface OtelMetricDefinition { + readonly instrument: OtelMetricInstrumentKind + readonly name: string + readonly description?: string + readonly unit?: string + readonly labels: OtelMetricLabels + readonly metadata: OtelMetricMetadata + readonly encodeLabels: ( + value: Schema.Schema.Type, + ) => Effect.Effect + readonly encodeLabelsSync: (value: Schema.Schema.Type) => OtelAttributeMap + readonly unsafeEncodeLabels: (value: Schema.Schema.Type) => OtelAttributeMap + readonly tagPairs: ( + value: Schema.Schema.Type, + ) => Effect.Effect, OtelAttrEncodeError> + readonly trustedTagPairs: ( + value: Schema.Schema.Type, + ) => Effect.Effect> +} + +export interface OtelHistogramDefinition< + S extends Schema.Schema.AnyNoContext, +> extends OtelMetricDefinition { + readonly instrument: 'histogram' + readonly boundaries?: ReadonlyArray +} + +export type OtelEffectCounterMetric = Metric.Metric.Counter +export type OtelEffectHistogramMetric = Metric.Metric.Histogram + +/** Effect Metric runtime bridge for a schema-first counter contract. */ +export interface OtelEffectCounter { + readonly definition: OtelMetricDefinition + readonly metric: OtelEffectCounterMetric + readonly increment: (labels: Schema.Schema.Type) => Effect.Effect + readonly incrementBy: ( + labels: Schema.Schema.Type, + amount: number, + ) => Effect.Effect + readonly trustedIncrement: (labels: Schema.Schema.Type) => Effect.Effect + readonly trustedIncrementBy: ( + labels: Schema.Schema.Type, + amount: number, + ) => Effect.Effect +} + +/** Effect Metric runtime bridge for a schema-first histogram contract. */ +export interface OtelEffectHistogram { + readonly definition: OtelHistogramDefinition + readonly metric: OtelEffectHistogramMetric + readonly record: ( + labels: Schema.Schema.Type, + value: number, + ) => Effect.Effect + readonly trustedRecord: (labels: Schema.Schema.Type, value: number) => Effect.Effect +} + +const getAttrMetadata = (annotated: AST.Annotated): OtelAttrMetadata | undefined => + Option.getOrUndefined(AST.getAnnotation(annotated, OtelAttrAnnotationId)) + +const getAttrMetadataDeep = (ast: AST.AST): OtelAttrMetadata | undefined => { + const metadata = getAttrMetadata(ast) + if (metadata !== undefined) return metadata + switch (ast._tag) { + case 'Refinement': + return getAttrMetadataDeep(ast.from) + case 'Transformation': + return getAttrMetadataDeep(ast.to) ?? getAttrMetadataDeep(ast.from) + case 'Union': + return ast.types + .filter((member) => isUndefinedAst(member) === false) + .map(getAttrMetadataDeep) + .find((memberMetadata) => memberMetadata !== undefined) + default: + return undefined + } +} + +const withAttrMetadata = + (metadata: OtelAttrMetadata) => + (schema: S): Schema.Annotable.Self => + Schema.make, Schema.Schema.Encoded, Schema.Schema.Context>( + addAnnotation({ ast: schema.ast, metadata }), + ) as Schema.Annotable.Self + +const addAnnotation = ({ + ast, + metadata, +}: { + readonly ast: AST.AST + readonly metadata: OtelAttrMetadata +}): AST.AST => { + const descriptors: PropertyDescriptorMap = Object.getOwnPropertyDescriptors(ast) + descriptors.annotations = { + configurable: true, + enumerable: true, + value: { + ...ast.annotations, + [OtelAttrAnnotationId]: { + ...getAttrMetadata(ast), + ...metadata, + }, + }, + writable: true, + } + return Object.create(Object.getPrototypeOf(ast), descriptors) as AST.AST +} + +/** Schema annotation helpers for deriving OTEL attribute keys and encoding policies. */ +export const OtelAttr = { + key: (metadata: { readonly key: string } & Omit) => + withAttrMetadata(metadata), + spanLabel: (metadata: Omit = {}) => + withAttrMetadata({ ...metadata, key: 'span.label', role: 'span.label' }), + encode: (encode: OtelAttrEncodePolicy) => withAttrMetadata({ encode }), + cardinality: (cardinality: NonNullable) => + withAttrMetadata({ cardinality }), + string: ( + key: string, + metadata: Omit = {}, + ): Schema.Schema => Schema.String.pipe(OtelAttr.key({ ...metadata, key })), + boolean: ( + key: string, + metadata: Omit = {}, + ): Schema.Schema => + Schema.Boolean.pipe(OtelAttr.key({ cardinality: 'low', ...metadata, key })), + number: ( + key: string, + metadata: Omit = {}, + ): Schema.Schema => Schema.Number.pipe(OtelAttr.key({ ...metadata, key })), + literal: ]>( + key: string, + ...values: Literals + ): Schema.Literal => + Schema.Literal(...values).pipe( + OtelAttr.key({ key, cardinality: values.length <= 2 ? 'low' : 'bounded' }), + ) as Schema.Literal, + optional: (schema: S) => Schema.optional(schema), + redacted: (key: string): Schema.Schema, string, never> => + Schema.Redacted(Schema.String).pipe(OtelAttr.key({ key, encode: 'redacted' })), + json: ( + key: string, + schema: S, + metadata: Omit = {}, + ): S => schema.pipe(OtelAttr.key({ ...metadata, key, encode: 'json' })) as S, + drop: (schema: S): S => + schema.pipe(OtelAttr.encode('drop')) as S, +} as const + +const unsupported = ({ + path, + message, +}: { + readonly path: ReadonlyArray + readonly message: string +}) => + new OtelAttrPlanError({ + path: path.map(String), + message, + }) + +const primitiveEncodeError = ({ key, value }: { readonly key: string; readonly value: unknown }) => + new OtelAttrEncodeError({ + key, + message: `Encoded value for ${key} is not an OTEL primitive: ${String(value)}`, + }) + +const missingSpanLabelError = () => + new OtelAttrEncodeError({ + key: 'span.label', + message: 'OtelSpan.with requires encoded attributes to include span.label', + }) + +const encodeFailure = ({ key, cause }: { readonly key: string; readonly cause: unknown }) => + new OtelAttrEncodeError({ + key, + message: `Failed to encode OTEL attribute ${key}`, + cause, + }) + +const isPrimitive = (value: unknown): value is OtelPrimitive => + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + +const isFiniteOtelNumber = (value: number): boolean => Number.isFinite(value) + +const primitiveFromUnknown = ({ + key, + value, +}: { + readonly key: string + readonly value: unknown +}) => { + if (typeof value === 'number' && isFiniteOtelNumber(value) === false) { + return Either.left( + new OtelAttrEncodeError({ + key, + message: `OTEL number attribute ${key} must be finite`, + }), + ) + } + return isPrimitive(value) === true + ? Either.right(value) + : Either.left(primitiveEncodeError({ key, value })) +} + +const effectFromEither = (either: Either.Either): Effect.Effect => + Either.isRight(either) === true ? Effect.succeed(either.right) : Effect.fail(either.left) + +const runSyncOrThrow = (effect: Effect.Effect): A => + Exit.match(Effect.runSyncExit(effect), { + onSuccess: (value) => value, + onFailure: (cause) => { + const failure = Option.getOrUndefined(Cause.failureOption(cause)) + if (failure !== undefined) throw failure + throw Cause.squash(cause) + }, + }) + +const encodeUnknown = ({ + key, + schema, + value, +}: { + readonly key: string + readonly schema: Schema.Schema + readonly value: unknown +}) => + effectFromEither(Schema.encodeUnknownEither(schema)(value)).pipe( + Effect.mapError((cause) => encodeFailure({ key, cause })), + ) + +const astIdentifier = (ast: AST.AST): string | undefined => + Option.getOrUndefined(AST.getIdentifierAnnotation(ast)) + +const typeConstructorTag = (ast: AST.AST): string | undefined => + Option.getOrUndefined(AST.getTypeConstructorAnnotation(ast))?._tag + +const typeConstructorTagDeep = (ast: AST.AST): string | undefined => + typeConstructorTag(ast) ?? + (ast._tag === 'Transformation' ? typeConstructorTagDeep(ast.to) : undefined) + +const typeConstructorParametersDeep = (ast: AST.AST): ReadonlyArray => { + if (ast._tag === 'Declaration') return ast.typeParameters + if (ast._tag === 'Transformation') return typeConstructorParametersDeep(ast.to) + return [] +} + +const unwrapRefinement = (ast: AST.AST): AST.AST => + ast._tag === 'Refinement' ? unwrapRefinement(ast.from) : ast + +const isUndefinedAst = (ast: AST.AST): boolean => + ast._tag === 'UndefinedKeyword' || + (ast._tag === 'Union' && ast.types.some((member) => isUndefinedAst(member))) + +const isPrimitiveAst = (ast: AST.AST): boolean => { + const unwrapped = unwrapRefinement(ast) + switch (unwrapped._tag) { + case 'StringKeyword': + case 'NumberKeyword': + case 'BooleanKeyword': + return true + case 'Literal': + return isPrimitive(unwrapped.literal) + case 'Union': + return unwrapped.types + .filter((member) => isUndefinedAst(member) === false) + .every(isPrimitiveAst) + case 'TemplateLiteral': + return true + default: + return false + } +} + +const inferCardinality = ( + ast: AST.AST, +): NonNullable | undefined => { + const unwrapped = unwrapRefinement(ast) + switch (unwrapped._tag) { + case 'BooleanKeyword': + return 'low' + case 'Literal': + return typeof unwrapped.literal === 'boolean' ? 'low' : 'bounded' + case 'Union': { + const members = unwrapped.types.filter((member) => isUndefinedAst(member) === false) + if (members.length === 0) return undefined + if (members.every((member) => unwrapRefinement(member)._tag === 'Literal') === false) { + return undefined + } + return members.length <= 2 ? 'low' : 'bounded' + } + default: + return undefined + } +} + +const rootTypeLiteral = (schema: Schema.Schema.AnyNoContext) => { + const ast = schema.ast + if (ast._tag === 'TypeLiteral') return ast + if (ast._tag === 'Transformation' && ast.to._tag === 'TypeLiteral') return ast.to + return undefined +} + +const compileAutoEncoder = ({ + attrKey, + path, + schema, +}: { + readonly attrKey: string + readonly path: ReadonlyArray + readonly schema: Schema.Schema +}): Effect.Effect => { + const ast = schema.ast + const tag = typeConstructorTagDeep(ast) + if (tag === 'effect/Redacted') { + return Effect.fail( + unsupported({ path, message: 'Redacted attributes require OtelAttr.encode("redacted")' }), + ) + } + if (tag === 'effect/Option') { + const valueAst = typeConstructorParametersDeep(ast)[0] + if (valueAst === undefined || isPrimitiveAst(valueAst) === false) { + return Effect.fail( + unsupported({ path, message: 'Option attributes must wrap a primitive-safe schema' }), + ) + } + return Effect.succeed((value) => + Effect.gen(function* () { + const encoded = yield* encodeUnknown({ key: attrKey, schema, value }) + if (encoded === null || encoded === undefined) return undefined + return yield* effectFromEither(primitiveFromUnknown({ key: attrKey, value: encoded })) + }), + ) + } + if (tag === 'effect/Duration') { + if (astIdentifier(ast) !== 'DurationFromMillis') { + return Effect.fail( + unsupported({ + path, + message: 'Duration attributes must use DurationFromMillis or an explicit encoder', + }), + ) + } + return Effect.succeed((value) => + Effect.succeed(Duration.toMillis(value as Duration.DurationInput)), + ) + } + if (tag === 'effect/DateTime.Utc') { + return Effect.succeed((value) => Effect.succeed(DateTime.formatIso(value as DateTime.Utc))) + } + if (ast._tag === 'TypeLiteral') { + return Effect.fail( + unsupported({ path, message: 'Nested Struct attributes require an explicit encoder' }), + ) + } + if (ast._tag === 'TupleType') { + return Effect.fail( + unsupported({ + path, + message: 'Array attributes require OtelAttr.encode("json") or OtelAttr.encode("string")', + }), + ) + } + if (isPrimitiveAst(ast) === false && ast._tag !== 'Transformation') { + return Effect.fail( + unsupported({ path, message: `Unsupported OTEL attribute schema: ${String(ast)}` }), + ) + } + + return Effect.succeed((value) => + encodeUnknown({ key: attrKey, schema, value }).pipe( + Effect.flatMap((encoded) => + encoded === null || encoded === undefined + ? Effect.succeed(undefined) + : effectFromEither(primitiveFromUnknown({ key: attrKey, value: encoded })), + ), + ), + ) +} + +const compilePolicyEncoder = ({ + attrKey, + policy, + schema, +}: { + readonly attrKey: string + readonly policy: Exclude + readonly schema: Schema.Schema +}): FieldEncoder => { + switch (policy) { + case 'drop': + return () => Effect.succeed(undefined) + case 'redacted': + return (value) => + Redacted.isRedacted(value) === true + ? encodeUnknown({ key: attrKey, schema, value }).pipe(Effect.as('')) + : Effect.fail(encodeFailure({ key: attrKey, cause: value })) + case 'json': + return (value) => + encodeUnknown({ key: attrKey, schema, value }).pipe( + Effect.flatMap((encoded) => + Effect.try({ + try: () => { + const json = JSON.stringify(encoded) + if (json === undefined) throw new Error('JSON.stringify returned undefined') + return json + }, + catch: (cause) => encodeFailure({ key: attrKey, cause }), + }), + ), + ) + case 'string': + return (value) => + encodeUnknown({ key: attrKey, schema, value }).pipe( + Effect.map((encoded) => String(encoded)), + ) + case 'number': + return (value) => + encodeUnknown({ key: attrKey, schema, value }).pipe( + Effect.flatMap((encoded) => + typeof encoded === 'number' && isFiniteOtelNumber(encoded) === true + ? Effect.succeed(encoded) + : Effect.fail(primitiveEncodeError({ key: attrKey, value: encoded })), + ), + ) + case 'boolean': + return (value) => + encodeUnknown({ key: attrKey, schema, value }).pipe( + Effect.flatMap((encoded) => + typeof encoded === 'boolean' + ? Effect.succeed(encoded) + : Effect.fail(primitiveEncodeError({ key: attrKey, value: encoded })), + ), + ) + } +} + +const compileField = ( + field: AST.PropertySignature, +): Effect.Effect => { + const metadata = getAttrMetadataDeep(field.type) ?? getAttrMetadata(field) + const fieldSchema = Schema.make(field.type) + return Effect.gen(function* () { + const attrKey = yield* decodeAttributeKey(metadata?.key ?? String(field.name)) + const tag = typeConstructorTagDeep(field.type) + if ( + tag === 'effect/Redacted' && + metadata?.encode !== undefined && + metadata.encode !== 'auto' && + metadata.encode !== 'redacted' && + metadata.encode !== 'drop' + ) { + return yield* unsupported({ + path: [field.name], + message: 'Redacted attributes only support OtelAttr.encode("redacted") or "drop"', + }) + } + const encode = + metadata?.encode === undefined || metadata.encode === 'auto' + ? yield* compileAutoEncoder({ attrKey, path: [field.name], schema: fieldSchema }) + : compilePolicyEncoder({ attrKey, policy: metadata.encode, schema: fieldSchema }) + const encodePolicy = metadata?.encode ?? 'auto' + const cardinality = metadata?.cardinality ?? inferCardinality(field.type) + const schemaIdentifier = astIdentifier(field.type) + return { + sourceKey: field.name, + attrKey, + ...(metadata?.role === undefined ? {} : { role: metadata.role }), + optional: field.isOptional || isUndefinedAst(field.type), + encodePolicy, + ...(cardinality === undefined ? {} : { cardinality }), + ...(schemaIdentifier === undefined ? {} : { schemaIdentifier }), + astTag: field.type._tag, + encode, + } + }) +} + +const fieldMetadata = (field: FieldPlan): OtelAttrFieldMetadata => ({ + sourceKey: String(field.sourceKey), + attrKey: field.attrKey, + ...(field.role === undefined ? {} : { role: field.role }), + optional: field.optional, + encodePolicy: field.encodePolicy, + ...(field.cardinality === undefined ? {} : { cardinality: field.cardinality }), + ...(field.schemaIdentifier === undefined ? {} : { schemaIdentifier: field.schemaIdentifier }), + astTag: field.astTag, +}) + +const compilePlan = ( + schema: Schema.Schema.AnyNoContext, +): Effect.Effect, OtelAttrPlanError> => + Effect.gen(function* () { + const root = rootTypeLiteral(schema) + if (root === undefined) { + return yield* unsupported({ + path: [], + message: 'OtelAttrs.define requires a Struct-like schema', + }) + } + if (root.indexSignatures.length > 0) { + return yield* unsupported({ + path: [], + message: 'Record/index-signature attributes require an explicit encoder', + }) + } + const plans = yield* Effect.all(root.propertySignatures.map(compileField)) + const seen = new Set() + for (const plan of plans) { + if (seen.has(plan.attrKey) === true) { + return yield* unsupported({ + path: [plan.sourceKey], + message: `Duplicate OTEL attribute key: ${plan.attrKey}`, + }) + } + seen.add(plan.attrKey) + } + return plans + }) + +/** Constructors for schema-backed OTEL attribute contracts. */ +export const OtelAttrs = { + define( + schema: S, + ): Effect.Effect, OtelAttrPlanError> { + return Effect.gen(function* () { + const plan = yield* compilePlan(schema) + const encode = (value: Schema.Schema.Type) => + Effect.gen(function* () { + const out: Record = {} + for (const field of plan) { + const valueRecord = value as Record + const raw = valueRecord[field.sourceKey] + if (raw === undefined && field.optional === true) continue + const encoded = yield* field.encode(raw) + if (encoded !== undefined) out[field.attrKey] = encoded + } + return out + }) + return { + schema, + keys: new Set(plan.map((field) => field.attrKey)), + fields: plan.map(fieldMetadata), + hasSpanLabel: plan.some( + (field) => field.attrKey === 'span.label' && field.role === 'span.label', + ), + encode, + encodeSync: (value) => runSyncOrThrow(encode(value)), + unsafeEncode: (value) => runSyncOrThrow(encode(value)), + } + }) + }, + defineSync(schema: S): OtelAttrs { + return runSyncOrThrow(OtelAttrs.define(schema)) + }, +} + +function withSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect +}): Effect.Effect +function withSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type +}): (effect: Effect.Effect) => Effect.Effect +function withSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type + readonly effect?: Effect.Effect +}) { + const wrap = (effect: Effect.Effect) => + Effect.gen(function* () { + const attributes = yield* options.span.attributes.encode(options.attributes) + if (attributes['span.label'] === undefined) return yield* missingSpanLabelError() + return yield* effect.pipe( + Effect.withSpan(options.span.name, { + attributes, + ...(options.span.root === undefined ? {} : { root: options.span.root }), + }), + ) + }) + return options.effect === undefined ? wrap : wrap(options.effect) +} + +function unsafeWithSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect +}): Effect.Effect +function unsafeWithSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type +}): (effect: Effect.Effect) => Effect.Effect +function unsafeWithSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type + readonly effect?: Effect.Effect +}) { + const wrap = (effect: Effect.Effect) => + effect.pipe( + Effect.withSpan(options.span.name, { + attributes: options.span.attributes.unsafeEncode(options.attributes), + ...(options.span.root === undefined ? {} : { root: options.span.root }), + }), + ) + return options.effect === undefined ? wrap : wrap(options.effect) +} + +function withStreamSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type + readonly stream: Stream.Stream +}): Stream.Stream +function withStreamSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type +}): (stream: Stream.Stream) => Stream.Stream +function withStreamSpanContract(options: { + readonly span: OtelSpanDefinition + readonly attributes: Schema.Schema.Type + readonly stream?: Stream.Stream +}) { + const wrap = (stream: Stream.Stream) => + Stream.unwrap( + Effect.gen(function* () { + const attributes = yield* options.span.attributes.encode(options.attributes) + if (attributes['span.label'] === undefined) return yield* missingSpanLabelError() + return stream.pipe( + Stream.withSpan(options.span.name, { + attributes, + ...(options.span.root === undefined ? {} : { root: options.span.root }), + }), + ) + }), + ) + return options.stream === undefined ? wrap : wrap(options.stream) +} + +const spanMetadata = ( + options: Omit, 'metadata'>, +): OtelSpanMetadata => ({ + kind: 'span', + name: decodeSpanNameSync(options.name), + root: options.root === true, + attributes: options.attributes.fields, + attributeKeys: Array.from(options.attributes.keys), + hasSpanLabel: options.attributes.hasSpanLabel, +}) + +const normalizeSpanLabel = (label: string): Either.Either => { + const normalized = label.trim() + if (normalized.length === 0) { + return Either.left( + new OtelAttrEncodeError({ + key: 'span.label', + message: 'OtelOperation label must be a non-empty string', + }), + ) + } + return Either.right(normalized) +} + +const operationMetadata = (options: { + readonly name: string + readonly root?: boolean + readonly attributes: OtelAttrs +}): OtelOperationMetadata => ({ + kind: 'operation', + name: decodeSpanNameSync(options.name), + root: options.root === true, + attributes: options.attributes.fields, + attributeKeys: Array.from(new Set([...options.attributes.keys, 'span.label'])), + derivesSpanLabel: true, +}) + +const metricLabelsMetadata = ( + attributes: OtelAttrs, +): OtelMetricLabelsMetadata => ({ + kind: 'metric.labels', + labels: attributes.fields, + labelKeys: Array.from(attributes.keys), +}) + +const invalidMetricLabel = (field: OtelAttrFieldMetadata, message: string) => + new OtelAttrPlanError({ + path: [field.sourceKey], + message, + }) + +const assertMetricLabels = ( + attributes: OtelAttrs, +): OtelMetricLabels => { + for (const field of attributes.fields) { + if (field.encodePolicy === 'drop') { + throw invalidMetricLabel(field, `Metric label ${field.attrKey} cannot use a drop encoder`) + } + if (field.cardinality === undefined) { + throw invalidMetricLabel( + field, + `Metric label ${field.attrKey} must declare or infer low/bounded cardinality`, + ) + } + if (field.cardinality === 'high') { + throw invalidMetricLabel(field, `Metric label ${field.attrKey} cannot use high cardinality`) + } + } + const metadata = metricLabelsMetadata(attributes) + return { + schema: attributes.schema, + attributes, + metadata, + encode: attributes.encode, + encodeSync: attributes.encodeSync, + unsafeEncode: attributes.unsafeEncode, + } +} + +const metricMetadata = (options: { + readonly instrument: OtelMetricInstrumentKind + readonly name: string + readonly description?: string + readonly unit?: string + readonly labels: OtelMetricLabels + readonly boundaries?: ReadonlyArray +}): OtelMetricMetadata => ({ + kind: 'metric', + instrument: options.instrument, + name: decodeMetricNameSync(options.name), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.unit === undefined ? {} : { unit: options.unit }), + labels: options.labels.metadata.labels, + labelKeys: options.labels.metadata.labelKeys, + ...(options.boundaries === undefined ? {} : { boundaries: options.boundaries }), +}) + +const validateHistogramBoundaries = ( + boundaries: ReadonlyArray | undefined, +): ReadonlyArray | undefined => { + if (boundaries === undefined) return undefined + let previous = Number.NEGATIVE_INFINITY + for (const boundary of boundaries) { + if (Number.isFinite(boundary) === false) { + throw new OtelAttrPlanError({ + path: ['boundaries'], + message: 'Histogram boundaries must be finite numbers', + }) + } + if (boundary <= previous) { + throw new OtelAttrPlanError({ + path: ['boundaries'], + message: 'Histogram boundaries must be strictly increasing', + }) + } + previous = boundary + } + return boundaries +} + +const encodeOperationAttributes = (options: { + readonly attributes: OtelAttrs + readonly label: (value: Schema.Schema.Type) => string + readonly value: Schema.Schema.Type +}) => + Effect.gen(function* () { + const attributes = yield* options.attributes.encode(options.value) + const label = yield* effectFromEither(normalizeSpanLabel(options.label(options.value))) + return { ...attributes, 'span.label': label } + }) + +const isEffectOperationCall = ( + call: + | { + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect + } + | Schema.Schema.Type, +): call is { + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect +} => typeof call === 'object' && call !== null && 'attributes' in call && 'effect' in call + +const isStreamOperationCall = ( + call: + | { + readonly attributes: Schema.Schema.Type + readonly stream: Stream.Stream + } + | Schema.Schema.Type, +): call is { + readonly attributes: Schema.Schema.Type + readonly stream: Stream.Stream +} => typeof call === 'object' && call !== null && 'attributes' in call && 'stream' in call + +function defineOperation(options: { + readonly name: string + readonly schema: S + readonly label: (value: Schema.Schema.Type) => string + readonly root?: boolean +}): OtelOperationDefinition +function defineOperation(options: { + readonly name: string + readonly attributes: OtelAttrs + readonly label: (value: Schema.Schema.Type) => string + readonly root?: boolean +}): OtelOperationDefinition +function defineOperation( + options: + | { + readonly name: string + readonly schema: S + readonly label: (value: Schema.Schema.Type) => string + readonly root?: boolean + } + | { + readonly name: string + readonly attributes: OtelAttrs + readonly label: (value: Schema.Schema.Type) => string + readonly root?: boolean + }, +): OtelOperationDefinition { + const name = decodeSpanNameSync(options.name) + const attributes = + 'attributes' in options ? options.attributes : OtelAttrs.defineSync(options.schema) + const encode = (value: Schema.Schema.Type) => + encodeOperationAttributes({ attributes, label: options.label, value }) + const metadata = operationMetadata({ + name, + attributes, + ...(options.root === undefined ? {} : { root: options.root }), + }) + + function withOperation( + call: + | { + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect + } + | Schema.Schema.Type, + ) { + const wrap = (effect: Effect.Effect) => + Effect.gen(function* () { + const encoded = yield* encode(isEffectOperationCall(call) ? call.attributes : call) + return yield* effect.pipe( + Effect.withSpan(name, { + attributes: encoded, + ...(options.root === undefined ? {} : { root: options.root }), + }), + ) + }) + return isEffectOperationCall(call) ? wrap(call.effect) : wrap + } + + function withRootOperation( + call: + | { + readonly attributes: Schema.Schema.Type + readonly effect: Effect.Effect + } + | Schema.Schema.Type, + ) { + const wrap = (effect: Effect.Effect) => + Effect.gen(function* () { + const encoded = yield* encode(isEffectOperationCall(call) ? call.attributes : call) + return yield* effect.pipe( + Effect.withSpan(name, { + attributes: encoded, + root: true, + }), + ) + }) + return isEffectOperationCall(call) ? wrap(call.effect) : wrap + } + + function withOperationStream( + call: + | { + readonly attributes: Schema.Schema.Type + readonly stream: Stream.Stream + } + | Schema.Schema.Type, + ) { + const wrap = (stream: Stream.Stream) => + Stream.unwrap( + Effect.gen(function* () { + const encoded = yield* encode(isStreamOperationCall(call) ? call.attributes : call) + return stream.pipe( + Stream.withSpan(name, { + attributes: encoded, + ...(options.root === undefined ? {} : { root: options.root }), + }), + ) + }), + ) + return isStreamOperationCall(call) ? wrap(call.stream) : wrap + } + + return { + name, + attributes, + ...(options.root === undefined ? {} : { root: options.root }), + metadata, + encode, + encodeSync: (value) => runSyncOrThrow(encode(value)), + unsafeEncode: (value) => runSyncOrThrow(encode(value)), + with: withOperation as OtelOperationDefinition['with'], + withRoot: withRootOperation as OtelOperationDefinition['withRoot'], + withStream: withOperationStream as OtelOperationDefinition['withStream'], + annotate: (value) => + Effect.gen(function* () { + const encoded = yield* encode(value) + yield* Effect.annotateCurrentSpan(encoded) + }), + } +} + +const defineMetricLabels = (schema: S): OtelMetricLabels => + assertMetricLabels(OtelAttrs.defineSync(schema)) + +const metricLabelsFromInput = ( + labels: S | OtelMetricLabels, +): OtelMetricLabels => ('metadata' in labels ? labels : defineMetricLabels(labels)) + +const metricTagPairs = + ( + encodeLabels: ( + value: Schema.Schema.Type, + ) => Effect.Effect, + ) => + ( + value: Schema.Schema.Type, + ): Effect.Effect, OtelAttrEncodeError> => + encodeLabels(value).pipe( + Effect.map((encoded) => + Object.entries(encoded).map(([key, labelValue]) => [key, String(labelValue)] as const), + ), + ) + +const trustedMetricTagPairs = + ( + encodeLabels: ( + value: Schema.Schema.Type, + ) => Effect.Effect, + ) => + (value: Schema.Schema.Type): Effect.Effect> => + metricTagPairs(encodeLabels)(value).pipe( + Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error)), + ) as Effect.Effect> + +const defineCounter = (options: { + readonly name: string + readonly description?: string + readonly unit?: string + readonly labels: S | OtelMetricLabels +}): OtelMetricDefinition => { + const name = decodeMetricNameSync(options.name) + const labels = metricLabelsFromInput(options.labels) + const tagPairs = metricTagPairs(labels.encode) + return { + instrument: 'counter', + name, + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.unit === undefined ? {} : { unit: options.unit }), + labels, + metadata: metricMetadata({ + instrument: 'counter', + name, + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.unit === undefined ? {} : { unit: options.unit }), + labels, + }), + encodeLabels: labels.encode, + encodeLabelsSync: labels.encodeSync, + unsafeEncodeLabels: labels.unsafeEncode, + tagPairs, + trustedTagPairs: trustedMetricTagPairs(labels.encode), + } +} + +const defineHistogram = (options: { + readonly name: string + readonly description?: string + readonly unit?: string + readonly boundaries?: ReadonlyArray + readonly labels: S | OtelMetricLabels +}): OtelHistogramDefinition => { + const name = decodeMetricNameSync(options.name) + const labels = metricLabelsFromInput(options.labels) + const boundaries = validateHistogramBoundaries(options.boundaries) + const tagPairs = metricTagPairs(labels.encode) + return { + instrument: 'histogram', + name, + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.unit === undefined ? {} : { unit: options.unit }), + ...(boundaries === undefined ? {} : { boundaries }), + labels, + metadata: metricMetadata({ + instrument: 'histogram', + name, + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.unit === undefined ? {} : { unit: options.unit }), + labels, + ...(boundaries === undefined ? {} : { boundaries }), + }), + encodeLabels: labels.encode, + encodeLabelsSync: labels.encodeSync, + unsafeEncodeLabels: labels.unsafeEncode, + tagPairs, + trustedTagPairs: trustedMetricTagPairs(labels.encode), + } +} + +const taggedMetric = ( + metric: Metric.Metric, + tags: ReadonlyArray, +): Metric.Metric => + tags.reduce>( + (tagged, [key, value]) => Metric.tagged(tagged, key, value), + metric, + ) + +const trustedMetricEmission = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const effectCounter = ( + definition: OtelMetricDefinition, +): OtelEffectCounter => { + if (definition.instrument !== 'counter') { + throw new OtelAttrPlanError({ + path: ['instrument'], + message: `OtelMetric.effect.counter requires a counter definition, got ${definition.instrument}`, + }) + } + const metric = + definition.description === undefined + ? Metric.counter(definition.name) + : Metric.counter(definition.name, { description: definition.description }) + const incrementBy = (labels: Schema.Schema.Type, amount: number) => + Effect.gen(function* () { + const tags = yield* definition.tagPairs(labels) + yield* Metric.incrementBy(taggedMetric(metric, tags), amount) + }) + + return { + definition, + metric, + increment: (labels) => incrementBy(labels, 1), + incrementBy, + trustedIncrement: (labels) => trustedMetricEmission(incrementBy(labels, 1)), + trustedIncrementBy: (labels, amount) => trustedMetricEmission(incrementBy(labels, amount)), + } +} + +const effectHistogram = ( + definition: OtelHistogramDefinition, +): OtelEffectHistogram => { + const metric = Metric.histogram( + definition.name, + MetricBoundaries.fromIterable(definition.boundaries ?? []), + definition.description, + ) + const record = (labels: Schema.Schema.Type, value: number) => + Effect.gen(function* () { + const tags = yield* definition.tagPairs(labels) + yield* Metric.update(taggedMetric(metric, tags), value) + }) + + return { + definition, + metric, + record, + trustedRecord: (labels, value) => trustedMetricEmission(record(labels, value)), + } +} + +/** Helpers for applying schema-backed span contracts to Effects. */ +export const OtelSpan = { + defineSync(options: { + readonly name: string + readonly schema: S + readonly root?: boolean + }): OtelSpanDefinition { + return OtelSpan.define({ + name: options.name, + attributes: OtelAttrs.defineSync(options.schema), + ...(options.root === undefined ? {} : { root: options.root }), + }) + }, + define(options: { + readonly name: string + readonly attributes: OtelAttrs + readonly root?: boolean + }): OtelSpanDefinition { + const name = decodeSpanNameSync(options.name) + if (options.attributes.hasSpanLabel !== true) { + throw new OtelAttrPlanError({ + path: ['span.label'], + message: 'OtelSpan.define requires an OtelAttr.spanLabel() attribute', + }) + } + return { + name, + attributes: options.attributes, + ...(options.root === undefined ? {} : { root: options.root }), + metadata: spanMetadata({ ...options, name }), + } + }, + with: withSpanContract, + withStream: withStreamSpanContract, + unsafeWith: unsafeWithSpanContract, + annotate(options: { + readonly attributes: OtelAttrs + readonly value: Schema.Schema.Type + }): Effect.Effect { + return Effect.gen(function* () { + const attrs = yield* options.attributes.encode(options.value) + yield* Effect.annotateCurrentSpan(attrs) + }) + }, + annotateMap(attributes: OtelAttributeMap): Effect.Effect { + return Effect.forEach( + Object.entries(attributes), + ([key, value]) => Effect.annotateCurrentSpan(key, value), + { discard: true }, + ) + }, + unsafeAnnotate(options: { + readonly attributes: OtelAttrs + readonly value: Schema.Schema.Type + }): Effect.Effect { + return Effect.annotateCurrentSpan(options.attributes.unsafeEncode(options.value)) + }, + unsafeAnnotateMap(attributes: OtelAttributeMap): Effect.Effect { + return OtelSpan.annotateMap(attributes) + }, +} + +/** User-facing schema-first operation API for product instrumentation. */ +export const OtelOperation = { + define: defineOperation, +} as const + +/** Runtime-light schema-first metric contract API. */ +export const OtelMetric = { + labels: defineMetricLabels, + counter: defineCounter, + histogram: defineHistogram, + defineCounter, + defineHistogram, + effect: { + counter: effectCounter, + histogram: effectHistogram, + }, +} as const diff --git a/packages/@overeng/otel-contract/src/mod.unit.test.ts b/packages/@overeng/otel-contract/src/mod.unit.test.ts new file mode 100644 index 000000000..b885c0bc7 --- /dev/null +++ b/packages/@overeng/otel-contract/src/mod.unit.test.ts @@ -0,0 +1,897 @@ +import { DateTime, Duration, Effect, Metric, Option, Redacted, Schema, Stream } from 'effect' +import { describe, expect, it } from 'vitest' + +import { expectTrace } from '@overeng/utils-dev/otelite' + +import { + OtelAttr, + OtelAttrEncodeError, + OtelAttrPlanError, + OtelAttrs, + OtelAttributeKey, + OtelMetric, + OtelMetricName, + OtelOperation, + OtelServiceName, + OtelSpan, + OtelSpanName, +} from './mod.ts' + +describe('OTEL schema names', () => { + it('exports branded refined schemas for contract names and keys', async () => { + await expect( + Effect.runPromise(Schema.decodeUnknown(OtelAttributeKey)('service.name')), + ).resolves.toBe('service.name') + await expect( + Effect.runPromise(Schema.decodeUnknown(OtelAttributeKey)('notion-react.page_id')), + ).resolves.toBe('notion-react.page_id') + await expect( + Effect.runPromise(Schema.decodeUnknown(OtelSpanName)('notion-md.pull-page')), + ).resolves.toBe('notion-md.pull-page') + await expect( + Effect.runPromise(Schema.decodeUnknown(OtelMetricName)('restate_invocations_total')), + ).resolves.toBe('restate_invocations_total') + await expect( + Effect.runPromise(Schema.decodeUnknown(OtelServiceName)('notion-md-cli')), + ).resolves.toBe('notion-md-cli') + }) + + it('rejects invalid contract names and attribute keys at definition time', async () => { + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + value: OtelAttr.string('bad key'), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + + const SpanAttrs = OtelAttrs.defineSync( + Schema.Struct({ + label: OtelAttr.string('span.label', { role: 'span.label' }), + }), + ) + + expect(() => OtelSpan.define({ name: ' ', attributes: SpanAttrs })).toThrow(OtelAttrPlanError) + expect(() => + OtelOperation.define({ + name: 'bad\noperation', + schema: Schema.Struct({ value: OtelAttr.string('test.value') }), + label: ({ value }) => value, + }), + ).toThrow(OtelAttrPlanError) + expect(() => + OtelMetric.counter({ + name: 'bad metric', + labels: Schema.Struct({ + status: OtelAttr.literal('status', 'ok', 'failed'), + }), + }), + ).toThrow(OtelAttrPlanError) + }) +}) + +describe('OtelAttrs', () => { + it('derives primitive, literal, uuid, option, date, duration, and explicit array attributes', async () => { + const Attrs = Schema.Struct({ + label: Schema.NonEmptyTrimmedString.pipe(OtelAttr.spanLabel()), + requestId: Schema.UUID.pipe(OtelAttr.key({ key: 'request.id' })), + outcome: Schema.Literal('approved', 'denied', 'timeout').pipe( + OtelAttr.key({ key: 'op.outcome' }), + ), + count: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'op.count' })), + cacheHit: Schema.Boolean.pipe(OtelAttr.key({ key: 'op.cache_hit' })), + maybeShard: Schema.OptionFromNullOr(Schema.String).pipe(OtelAttr.key({ key: 'op.shard' })), + at: Schema.DateTimeUtc.pipe(OtelAttr.key({ key: 'op.at' })), + latency: Schema.DurationFromMillis.pipe(OtelAttr.key({ key: 'op.latency_ms' })), + tags: Schema.Array(Schema.String).pipe(OtelAttr.key({ key: 'op.tags', encode: 'json' })), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + const at = DateTime.unsafeMake('2026-06-11T10:00:00.000Z') + + await expect( + Effect.runPromise( + attrs.encode({ + label: 'submit', + requestId: '123e4567-e89b-12d3-a456-426614174000', + outcome: 'approved', + count: 2, + cacheHit: false, + maybeShard: Option.some('dev3'), + at, + latency: Duration.millis(42), + tags: ['safe', 'bounded'], + }), + ), + ).resolves.toEqual({ + 'span.label': 'submit', + 'request.id': '123e4567-e89b-12d3-a456-426614174000', + 'op.outcome': 'approved', + 'op.count': 2, + 'op.cache_hit': false, + 'op.shard': 'dev3', + 'op.at': '2026-06-11T10:00:00.000Z', + 'op.latency_ms': 42, + 'op.tags': '["safe","bounded"]', + }) + + await expect( + Effect.runPromise( + attrs.encode({ + label: 'submit', + requestId: '123e4567-e89b-12d3-a456-426614174000', + outcome: 'approved', + count: 2, + cacheHit: false, + maybeShard: Option.none(), + at, + latency: Duration.millis(42), + tags: [], + }), + ), + ).resolves.not.toHaveProperty('op.shard') + }) + + it('rejects unsafe schemas unless policy is explicit', async () => { + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + nested: Schema.Struct({ id: Schema.String }).pipe(OtelAttr.key({ key: 'nested' })), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe(OtelAttr.key({ key: 'secret' })), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + tags: Schema.Array(Schema.String).pipe(OtelAttr.key({ key: 'tags' })), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + }) + + it('allows explicit redacted and json policies', async () => { + const Attrs = Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'redacted' }), + ), + nested: Schema.Struct({ id: Schema.String }).pipe( + OtelAttr.key({ key: 'nested', encode: 'json' }), + ), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + + await expect( + Effect.runPromise( + attrs.encode({ + secret: Redacted.make('do-not-leak'), + nested: { id: 'n1' }, + }), + ), + ).resolves.toEqual({ + secret: '', + nested: '{"id":"n1"}', + }) + }) + + it('only allows redacted-safe policies for redacted values', async () => { + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'json' }), + ), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + + const attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'drop' }), + ), + }), + ), + ) + + await expect( + Effect.runPromise(attrs.encode({ secret: Redacted.make('do-not-leak') })), + ).resolves.toEqual({}) + }) + + it('surfaces encoding errors on the error channel', async () => { + const Attrs = Schema.Struct({ + count: Schema.Number.pipe(OtelAttr.key({ key: 'count' })), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + + await expect( + Effect.runPromise(Effect.either(attrs.encode({ count: Number.NaN }))), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrEncodeError), + }) + }) + + it('preserves typed contract errors in sync APIs', async () => { + expect(() => + OtelAttrs.defineSync( + Schema.Struct({ + nested: Schema.Struct({ id: Schema.String }).pipe(OtelAttr.key({ key: 'nested' })), + }), + ), + ).toThrow(OtelAttrPlanError) + + const attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + count: Schema.Number.pipe(OtelAttr.key({ key: 'count' })), + }), + ), + ) + + expect(() => attrs.encodeSync({ count: Number.NaN })).toThrow(OtelAttrEncodeError) + expect(() => attrs.unsafeEncode({ count: Number.NaN })).toThrow(OtelAttrEncodeError) + }) + + it('validates explicit policy inputs before encoding', async () => { + const Attrs = Schema.Struct({ + asJson: Schema.Struct({ id: Schema.String }).pipe( + OtelAttr.key({ key: 'json', encode: 'json' }), + ), + asString: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'string', encode: 'string' })), + asNumber: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'number', encode: 'number' })), + asBoolean: Schema.Boolean.pipe(OtelAttr.key({ key: 'boolean', encode: 'boolean' })), + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'redacted' }), + ), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + + const invalidInputs = [ + { + asJson: { id: 1 }, + asString: 1, + asNumber: 1, + asBoolean: true, + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: -1, + asNumber: 1, + asBoolean: true, + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: 1, + asNumber: Number.NaN, + asBoolean: true, + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: 1, + asNumber: 1, + asBoolean: 'true', + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: 1, + asNumber: 1, + asBoolean: true, + secret: Redacted.make(1), + }, + ] + const results = await Promise.all( + invalidInputs.map((invalid) => + Effect.runPromise(Effect.either(attrs.encode(invalid as never))), + ), + ) + + for (const result of results) { + expect(result).toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrEncodeError), + }) + } + }) + + it('feeds compiled attributes into otelite trace expectations', async () => { + const attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + label: Schema.String.pipe(OtelAttr.spanLabel()), + count: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'retry.count' })), + }), + ), + ) + const span = OtelSpan.define({ name: 'rpc.op.submit', attributes: attrs }) + const trace = expectTrace([ + { + schema: 'otelite.span/v1', + service: 'op-proxy', + name: 'rpc.op.submit', + trace_id: 'trace-1', + span_id: 'span-1', + parent_span_id: null, + start_unix_nano: '1', + end_unix_nano: '2', + duration_ms: 1, + status_code: 0, + attrs: { + 'span.label': 'read', + 'retry.count': '2', + }, + }, + ]) + + expect( + trace.expectAttributes({ + attributes: attrs, + match: { label: 'read', count: 2 }, + }), + ).toHaveLength(1) + expect( + trace.expectSpan({ + span, + match: { label: 'read', count: 2 }, + }).span_id, + ).toBe('span-1') + }) + + it('exposes compiled metadata for docs, lint, and future metric contracts', async () => { + const attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + label: Schema.NonEmptyTrimmedString.pipe(OtelAttr.spanLabel()), + outcome: OtelAttr.literal('op.outcome', 'success', 'retryable', 'terminal'), + cacheHit: OtelAttr.boolean('op.cache_hit'), + requestId: OtelAttr.string('request.id', { cardinality: 'high' }), + payload: OtelAttr.json('op.payload', Schema.Struct({ id: Schema.String })), + }), + ), + ) + + expect(attrs.fields).toMatchInlineSnapshot(` + [ + { + "astTag": "Refinement", + "attrKey": "span.label", + "encodePolicy": "auto", + "optional": false, + "role": "span.label", + "schemaIdentifier": "NonEmptyTrimmedString", + "sourceKey": "label", + }, + { + "astTag": "Union", + "attrKey": "op.outcome", + "cardinality": "bounded", + "encodePolicy": "auto", + "optional": false, + "sourceKey": "outcome", + }, + { + "astTag": "BooleanKeyword", + "attrKey": "op.cache_hit", + "cardinality": "low", + "encodePolicy": "auto", + "optional": false, + "sourceKey": "cacheHit", + }, + { + "astTag": "StringKeyword", + "attrKey": "request.id", + "cardinality": "high", + "encodePolicy": "auto", + "optional": false, + "sourceKey": "requestId", + }, + { + "astTag": "TypeLiteral", + "attrKey": "op.payload", + "encodePolicy": "json", + "optional": false, + "sourceKey": "payload", + }, + ] + `) + }) +}) + +describe('OtelSpan', () => { + it('wraps effects with schema-backed attributes', async () => { + const Attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + label: Schema.String.pipe(OtelAttr.spanLabel()), + }), + ), + ) + const span = OtelSpan.define({ name: 'test.span', attributes: Attrs }) + + await expect( + Effect.runPromise( + OtelSpan.with({ + span, + attributes: { label: 'contract' }, + effect: Effect.succeed('ok'), + }), + ), + ).resolves.toBe('ok') + + await expect( + Effect.runPromise( + Effect.succeed('ok').pipe(OtelSpan.with({ span, attributes: { label: 'pipe' } })), + ), + ).resolves.toBe('ok') + }) + + it('requires span.label at definition and runtime', async () => { + const WithoutLabel = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + value: Schema.String.pipe(OtelAttr.key({ key: 'value' })), + }), + ), + ) + expect(() => OtelSpan.define({ name: 'test.no-label', attributes: WithoutLabel })).toThrow( + OtelAttrPlanError, + ) + + const AccidentalLabel = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + value: Schema.String.pipe(OtelAttr.key({ key: 'span.label' })), + }), + ), + ) + expect(() => + OtelSpan.define({ name: 'test.accidental-label', attributes: AccidentalLabel }), + ).toThrow(OtelAttrPlanError) + + const WithOptionalLabel = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + label: Schema.optional(Schema.String.pipe(OtelAttr.spanLabel())), + }), + ), + ) + const span = OtelSpan.define({ name: 'test.optional-label', attributes: WithOptionalLabel }) + + await expect( + Effect.runPromise( + Effect.either( + OtelSpan.with({ + span, + attributes: {}, + effect: Effect.succeed('ok'), + }), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrEncodeError), + }) + }) + + it('wraps streams with schema-backed attributes', async () => { + const span = OtelSpan.defineSync({ + name: 'test.stream', + schema: Schema.Struct({ + label: OtelAttr.string('span.label', { role: 'span.label' }), + count: OtelAttr.number('stream.count'), + }), + }) + + await expect( + Effect.runPromise( + Stream.fromIterable([1, 2]).pipe( + OtelSpan.withStream({ span, attributes: { label: 'items', count: 2 } }), + Stream.runCollect, + ), + ), + ).resolves.toBeDefined() + }) +}) + +describe('OtelOperation', () => { + it('defines the normal user-facing operation API without a schema-level span label', async () => { + const PullPage = OtelOperation.define({ + name: 'notion-md.pull-page', + schema: Schema.Struct({ + pageId: OtelAttr.string('notion_md.page_id', { cardinality: 'high' }), + basename: OtelAttr.string('notion_md.path.basename'), + cacheHit: OtelAttr.boolean('notion_md.cache_hit'), + outcome: OtelAttr.literal('notion_md.outcome', 'created', 'updated', 'skipped'), + }), + label: ({ basename }) => basename, + }) + + await expect( + Effect.runPromise( + PullPage.encode({ + pageId: 'page-1', + basename: 'README.md', + cacheHit: true, + outcome: 'updated', + }), + ), + ).resolves.toEqual({ + 'span.label': 'README.md', + 'notion_md.page_id': 'page-1', + 'notion_md.path.basename': 'README.md', + 'notion_md.cache_hit': true, + 'notion_md.outcome': 'updated', + }) + + await expect( + Effect.runPromise( + PullPage.with({ + attributes: { + pageId: 'page-1', + basename: 'README.md', + cacheHit: true, + outcome: 'updated', + }, + effect: Effect.succeed('ok'), + }), + ), + ).resolves.toBe('ok') + + await expect( + Effect.runPromise( + Effect.succeed('ok').pipe( + PullPage.with({ + pageId: 'page-1', + basename: 'README.md', + cacheHit: true, + outcome: 'updated', + }), + ), + ), + ).resolves.toBe('ok') + + expect(PullPage.metadata).toMatchObject({ + kind: 'operation', + name: 'notion-md.pull-page', + root: false, + derivesSpanLabel: true, + attributeKeys: [ + 'notion_md.page_id', + 'notion_md.path.basename', + 'notion_md.cache_hit', + 'notion_md.outcome', + 'span.label', + ], + }) + }) + + it('rejects empty derived labels', async () => { + const Operation = OtelOperation.define({ + name: 'test.empty-label', + schema: Schema.Struct({ + value: OtelAttr.string('test.value'), + }), + label: () => ' ', + }) + + await expect( + Effect.runPromise(Effect.either(Operation.encode({ value: 'ok' }))), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrEncodeError), + }) + }) + + it('wraps root spans and streams through the operation contract', async () => { + const Operation = OtelOperation.define({ + name: 'test.operation.stream', + schema: Schema.Struct({ + value: OtelAttr.string('test.value'), + }), + label: ({ value }) => value, + }) + + await expect( + Effect.runPromise( + Operation.withRoot({ + attributes: { value: 'root' }, + effect: Effect.succeed('ok'), + }), + ), + ).resolves.toBe('ok') + + await expect( + Effect.runPromise( + Stream.fromIterable(['a', 'b']).pipe( + Operation.withStream({ value: 'stream' }), + Stream.runCollect, + ), + ), + ).resolves.toBeDefined() + }) +}) + +describe('OtelMetric', () => { + it('defines runtime-light counter metadata with schema-backed labels', async () => { + const Invocations = OtelMetric.counter({ + name: 'restate_invocations_total', + description: 'Restate invocations by service, handler, and outcome.', + unit: '1', + labels: Schema.Struct({ + service: OtelAttr.string('restate.service', { cardinality: 'bounded' }), + handler: OtelAttr.string('restate.handler', { cardinality: 'bounded' }), + outcome: OtelAttr.literal( + 'restate.outcome', + 'success', + 'terminal', + 'retryable', + 'cancelled', + ), + cacheHit: OtelAttr.boolean('restate.cache_hit'), + }), + }) + + await expect( + Effect.runPromise( + Invocations.encodeLabels({ + service: 'notion-sync', + handler: 'pull', + outcome: 'success', + cacheHit: true, + }), + ), + ).resolves.toEqual({ + 'restate.service': 'notion-sync', + 'restate.handler': 'pull', + 'restate.outcome': 'success', + 'restate.cache_hit': true, + }) + + expect(Invocations.metadata).toMatchInlineSnapshot(` + { + "description": "Restate invocations by service, handler, and outcome.", + "instrument": "counter", + "kind": "metric", + "labelKeys": [ + "restate.service", + "restate.handler", + "restate.outcome", + "restate.cache_hit", + ], + "labels": [ + { + "astTag": "StringKeyword", + "attrKey": "restate.service", + "cardinality": "bounded", + "encodePolicy": "auto", + "optional": false, + "sourceKey": "service", + }, + { + "astTag": "StringKeyword", + "attrKey": "restate.handler", + "cardinality": "bounded", + "encodePolicy": "auto", + "optional": false, + "sourceKey": "handler", + }, + { + "astTag": "Union", + "attrKey": "restate.outcome", + "cardinality": "bounded", + "encodePolicy": "auto", + "optional": false, + "sourceKey": "outcome", + }, + { + "astTag": "BooleanKeyword", + "attrKey": "restate.cache_hit", + "cardinality": "low", + "encodePolicy": "auto", + "optional": false, + "sourceKey": "cacheHit", + }, + ], + "name": "restate_invocations_total", + "unit": "1", + } + `) + + await expect( + Effect.runPromise( + Invocations.tagPairs({ + service: 'notion-sync', + handler: 'pull', + outcome: 'success', + cacheHit: true, + }), + ), + ).resolves.toEqual([ + ['restate.service', 'notion-sync'], + ['restate.handler', 'pull'], + ['restate.outcome', 'success'], + ['restate.cache_hit', 'true'], + ]) + + await expect( + Effect.runPromise( + Invocations.trustedTagPairs({ + service: 'notion-sync', + handler: 'pull', + outcome: 'success', + cacheHit: true, + }), + ), + ).resolves.toEqual([ + ['restate.service', 'notion-sync'], + ['restate.handler', 'pull'], + ['restate.outcome', 'success'], + ['restate.cache_hit', 'true'], + ]) + }) + + it('defines histogram metadata without owning runtime emission', () => { + const labels = OtelMetric.labels( + Schema.Struct({ + operation: OtelAttr.literal('operation', 'pull', 'push'), + }), + ) + const DurationMs = OtelMetric.histogram({ + name: 'operation_duration_ms', + description: 'Operation duration.', + unit: 'ms', + boundaries: [10, 50, 100, 500, 1000], + labels, + }) + + expect(DurationMs).not.toHaveProperty('increment') + expect(DurationMs).not.toHaveProperty('record') + expect(DurationMs.metadata).toMatchObject({ + kind: 'metric', + instrument: 'histogram', + name: 'operation_duration_ms', + unit: 'ms', + labelKeys: ['operation'], + boundaries: [10, 50, 100, 500, 1000], + }) + }) + + it('bridges schema-first counters to tagged Effect metrics', async () => { + const Counter = OtelMetric.counter({ + name: 'otel_contract_test_bridge_counter_total', + description: 'Test counter bridge.', + labels: Schema.Struct({ + service: OtelAttr.string('service', { cardinality: 'bounded' }), + cacheHit: OtelAttr.boolean('cache_hit'), + }), + }) + const bridge = OtelMetric.effect.counter(Counter) + + await Effect.runPromise( + Effect.all( + [ + bridge.incrementBy({ service: 'api', cacheHit: true }, 2), + bridge.trustedIncrement({ service: 'api', cacheHit: true }), + ], + { discard: true }, + ), + ) + + const pair = Metric.unsafeSnapshot(undefined).find((entry) => { + if (entry.metricKey.name !== 'otel_contract_test_bridge_counter_total') return false + const tags = Object.fromEntries(entry.metricKey.tags.map((tag) => [tag.key, tag.value])) + return tags.service === 'api' && tags.cache_hit === 'true' + }) + expect(pair?.metricState).toMatchObject({ count: 3 }) + }) + + it('bridges schema-first histograms to tagged Effect metrics', async () => { + const Histogram = OtelMetric.histogram({ + name: 'otel_contract_test_bridge_duration_ms', + description: 'Test histogram bridge.', + unit: 'ms', + boundaries: [10, 100, 1000], + labels: Schema.Struct({ + route: OtelAttr.literal('route', 'sync', 'async'), + }), + }) + const bridge = OtelMetric.effect.histogram(Histogram) + + await Effect.runPromise(bridge.trustedRecord({ route: 'sync' }, 42)) + + const pair = Metric.unsafeSnapshot(undefined).find((entry) => { + if (entry.metricKey.name !== 'otel_contract_test_bridge_duration_ms') return false + const tags = Object.fromEntries(entry.metricKey.tags.map((tag) => [tag.key, tag.value])) + return tags.route === 'sync' + }) + expect(pair?.metricState).toMatchObject({ count: 1, min: 42, max: 42, sum: 42 }) + }) + + it('rejects high-cardinality and unspecified-cardinality metric labels', () => { + expect(() => + OtelMetric.labels( + Schema.Struct({ + workflowId: OtelAttr.string('restate.workflow.id', { cardinality: 'high' }), + }), + ), + ).toThrow(OtelAttrPlanError) + + expect(() => + OtelMetric.labels( + Schema.Struct({ + service: OtelAttr.string('restate.service'), + }), + ), + ).toThrow(OtelAttrPlanError) + }) + + it('rejects invalid histogram boundaries', () => { + expect(() => + OtelMetric.histogram({ + name: 'bad_histogram', + boundaries: [10, 5], + labels: Schema.Struct({ + status: OtelAttr.literal('status', 'ok', 'failed'), + }), + }), + ).toThrow(OtelAttrPlanError) + + expect(() => + OtelMetric.histogram({ + name: 'nan_histogram', + boundaries: [Number.NaN], + labels: Schema.Struct({ + status: OtelAttr.literal('status', 'ok', 'failed'), + }), + }), + ).toThrow(OtelAttrPlanError) + }) +}) diff --git a/packages/@overeng/otel-contract/src/raw-otel-boundary.unit.test.ts b/packages/@overeng/otel-contract/src/raw-otel-boundary.unit.test.ts new file mode 100644 index 000000000..93ed6394e --- /dev/null +++ b/packages/@overeng/otel-contract/src/raw-otel-boundary.unit.test.ts @@ -0,0 +1,71 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { relative, resolve } from 'node:path' + +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const repoRoot = resolve(import.meta.dirname, '../../..', '..') +const packagesRoot = resolve(repoRoot, 'packages/@overeng') + +const rawOtelCall = /\b(?:Effect|Stream)\.(?:withSpan|annotateCurrentSpan)\s*\(/g +const rawMetricCall = /\bMetric\.(?:counter|histogram|tagged|increment|incrementBy|update)\s*\(/g + +const allowedRawOtelFiles = new Set([ + 'packages/@overeng/otel-contract/src/mod.ts', + 'packages/@overeng/notion-datasource-sync/src/observability/observability.ts', + 'packages/@overeng/oxc-config/src/no-raw-otel-primitives.ts', + 'packages/@overeng/utils-dev/src/otelite/otel.ts', +]) + +const isProductionSource = (path: string) => + path.endsWith('.ts') && + path.includes('/src/') && + path.includes('/node_modules/') === false && + path.includes('/dist/') === false && + path.includes('/examples/') === false && + path.includes('/__tests__/') === false && + /\.(?:test|unit\.test|integration\.test|e2e\.test)\.ts$/.test(path) === false + +const sourceFiles = (dir: string): ReadonlyArray => + readdirSync(dir).flatMap((entry) => { + if (entry === 'node_modules' || entry === 'dist') return [] + const path = resolve(dir, entry) + const stat = statSync(path) + if (stat.isDirectory() === true) return sourceFiles(path) + return isProductionSource(path) === true ? [path] : [] + }) + +const removeComments = (source: string) => + ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + removeComments: true, + target: ts.ScriptTarget.ESNext, + }, + }).outputText + +describe('raw OTEL boundary', () => { + it('routes production span instrumentation through schema-backed helpers', () => { + const violations = sourceFiles(packagesRoot).flatMap((path) => { + const relativePath = relative(repoRoot, path) + if (allowedRawOtelFiles.has(relativePath) === true) return [] + + const source = removeComments(readFileSync(path, 'utf8')) + return [...source.matchAll(rawOtelCall)].map((match) => `${relativePath}:${match[0]}`) + }) + + expect(violations).toEqual([]) + }) + + it('routes production metric instrumentation through schema-backed helpers', () => { + const violations = sourceFiles(packagesRoot).flatMap((path) => { + const relativePath = relative(repoRoot, path) + if (allowedRawOtelFiles.has(relativePath) === true) return [] + + const source = removeComments(readFileSync(path, 'utf8')) + return [...source.matchAll(rawMetricCall)].map((match) => `${relativePath}:${match[0]}`) + }) + + expect(violations).toEqual([]) + }) +}) diff --git a/packages/@overeng/otel-contract/tsconfig.json b/packages/@overeng/otel-contract/tsconfig.json new file mode 100644 index 000000000..0555836f5 --- /dev/null +++ b/packages/@overeng/otel-contract/tsconfig.json @@ -0,0 +1,51 @@ +// Generated file - DO NOT EDIT +// Source: tsconfig.json.genie.ts +{ + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "allowJs": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "plugins": [ + { + "name": "@effect/language-service", + "reportSuggestionsAsWarningsInTsc": true, + "pipeableMinArgCount": 2, + "diagnosticSeverity": { + "missedPipeableOpportunity": "warning", + "schemaUnionOfLiterals": "warning", + "anyUnknownInErrorContext": "warning", + "preferSchemaOverJson": "warning" + } + } + ], + "composite": true, + "rootDir": ".", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*"], + "references": [ + { + "path": "../utils-dev" + } + ] +} diff --git a/packages/@overeng/otel-contract/tsconfig.json.genie.ts b/packages/@overeng/otel-contract/tsconfig.json.genie.ts new file mode 100644 index 000000000..92aa304f7 --- /dev/null +++ b/packages/@overeng/otel-contract/tsconfig.json.genie.ts @@ -0,0 +1,14 @@ +import { + baseTsconfigCompilerOptions, + packageTsconfigCompilerOptions, +} from '../../../genie/internal.ts' +import { tsconfigJson, type TSConfigArgs } from '../genie/src/runtime/mod.ts' + +export default tsconfigJson({ + compilerOptions: { + ...baseTsconfigCompilerOptions, + ...packageTsconfigCompilerOptions, + }, + include: ['src/**/*'], + references: [{ path: '../utils-dev' }], +} satisfies TSConfigArgs) diff --git a/packages/@overeng/otel-contract/vitest.config.ts b/packages/@overeng/otel-contract/vitest.config.ts new file mode 100644 index 000000000..cfc83dc4c --- /dev/null +++ b/packages/@overeng/otel-contract/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.unit.test.ts'], + exclude: ['src/**/*.integration.test.ts'], + server: { deps: { inline: ['@effect/vitest'] } }, + }, +}) diff --git a/packages/@overeng/otelite/README.md b/packages/@overeng/otelite/README.md index 9d20c2b1f..40adac25c 100644 --- a/packages/@overeng/otelite/README.md +++ b/packages/@overeng/otelite/README.md @@ -16,6 +16,16 @@ nix run github:overengineeringstudio/effect-utils#otelite -- --version # or in a flake: inputs.effect-utils.packages..otelite ``` +Inside this repo's `devenv` shell, `otelite` is already on `PATH` for +`@overeng/utils-dev/otelite` tests. From a plain shell, either run the app +directly with `nix run .#otelite -- ...`, prepend the built package to `PATH`, +or point the typed wrapper at it explicitly: + +```bash +export OTELITE_BIN="$(nix build --no-link --print-out-paths .#otelite)/bin/otelite" +CI=1 pnpm --dir packages/@overeng/utils-dev exec vitest run --config vitest.config.ts src/otelite/Otelite.test.ts +``` + ## Usage ```bash diff --git a/packages/@overeng/oxc-config/src/mod.ts b/packages/@overeng/oxc-config/src/mod.ts index 036634550..f142e6493 100644 --- a/packages/@overeng/oxc-config/src/mod.ts +++ b/packages/@overeng/oxc-config/src/mod.ts @@ -9,6 +9,7 @@ * - no-external-imports: Disallow value imports from npm packages (for dependency-free modules) * - no-raw-nondeterminism: Ban raw nondeterminism outside a journaled Restate.run closure * - no-non-durable-wait: Ban non-durable Effect.sleep/Effect.timeout outside a journaled Restate.run closure + * - no-raw-otel-primitives: Ban raw Effect/Stream OTEL span primitives outside contract boundaries * * It also re-exports selected rules from eslint-plugin-storybook under the * `overeng/storybook/*` namespace for enforcing Storybook best practices. @@ -29,6 +30,7 @@ import { namedArgsRule } from './named-args.ts' import { noExternalImportsRule } from './no-external-imports.ts' import { noNonDurableWaitRule } from './no-non-durable-wait.ts' import { noRawNondeterminismRule } from './no-raw-nondeterminism.ts' +import { noRawOtelPrimitivesRule } from './no-raw-otel-primitives.ts' type Rules = { 'explicit-boolean-compare': typeof explicitBooleanCompareRule @@ -38,6 +40,7 @@ type Rules = { 'no-external-imports': typeof noExternalImportsRule 'no-non-durable-wait': typeof noNonDurableWaitRule 'no-raw-nondeterminism': typeof noRawNondeterminismRule + 'no-raw-otel-primitives': typeof noRawOtelPrimitivesRule 'storybook/meta-satisfies-type': (typeof storybookRules)['meta-satisfies-type'] 'storybook/default-exports': (typeof storybookRules)['default-exports'] 'storybook/story-exports': (typeof storybookRules)['story-exports'] @@ -56,6 +59,7 @@ const rules: Rules = { 'no-external-imports': noExternalImportsRule, 'no-non-durable-wait': noNonDurableWaitRule, 'no-raw-nondeterminism': noRawNondeterminismRule, + 'no-raw-otel-primitives': noRawOtelPrimitivesRule, // Re-exported storybook rules (use as overeng/storybook/*) 'storybook/meta-satisfies-type': storybookRules['meta-satisfies-type'], diff --git a/packages/@overeng/oxc-config/src/no-raw-otel-primitives.ts b/packages/@overeng/oxc-config/src/no-raw-otel-primitives.ts new file mode 100644 index 000000000..f05a0a389 --- /dev/null +++ b/packages/@overeng/oxc-config/src/no-raw-otel-primitives.ts @@ -0,0 +1,201 @@ +/** + * no-raw-otel-primitives oxlint rule. + * + * Bans direct use of raw Effect OpenTelemetry span/metric primitives in production code. + * Product code should route telemetry through schema-backed contracts from + * `@overeng/otel-contract` so span names, labels, attributes, metrics, and future + * cardinality policy have one source of truth. + * + * Tracked calls: + * + * - `Effect.withSpan(...)` + * - `Stream.withSpan(...)` + * - `Effect.annotateCurrentSpan(...)` + * - `Metric.counter(...)` + * - `Metric.histogram(...)` + * - `Metric.tagged(...)` + * - `Metric.increment(...)` / `Metric.incrementBy(...)` + * - `Metric.update(...)` + * - Aliased namespace imports, e.g. `import { Effect as E } from 'effect'` + * - Namespace imports, e.g. `EffectLib.Effect.withSpan(...)` + * - Direct imported identifiers from `effect`, e.g. `withSpan(...)` + * + */ + +// NOTE: Using `any` types because oxlint JS plugin API doesn't have TypeScript definitions yet + +type EffectImportTracker = { + readonly effectNamespaces: Set + readonly streamNamespaces: Set + readonly metricNamespaces: Set + readonly effectModuleNamespaces: Set + readonly directRawCalls: Set +} + +const rawEffectMembers = new Set(['withSpan', 'annotateCurrentSpan']) +const rawStreamMembers = new Set(['withSpan']) +const rawMetricMembers = new Set([ + 'counter', + 'histogram', + 'tagged', + 'increment', + 'incrementBy', + 'update', +]) + +const createTracker = (): EffectImportTracker => ({ + effectNamespaces: new Set(), + streamNamespaces: new Set(), + metricNamespaces: new Set(), + effectModuleNamespaces: new Set(), + directRawCalls: new Set(), +}) + +/** Track local bindings imported from the root `effect` package. */ +const trackEffectImport = (tracker: EffectImportTracker, node: any): void => { + if (node.source?.value !== 'effect') return + + for (const specifier of node.specifiers ?? []) { + if (specifier.importKind === 'type') continue + + if (specifier.type === 'ImportNamespaceSpecifier') { + const localName = specifier.local?.name + if (typeof localName === 'string') tracker.effectModuleNamespaces.add(localName) + continue + } + + if (specifier.type !== 'ImportSpecifier') continue + + const importedName = importSpecifierImportedName(specifier) + const localName = specifier.local?.name + if (typeof importedName !== 'string' || typeof localName !== 'string') continue + + if (importedName === 'Effect') tracker.effectNamespaces.add(localName) + if (importedName === 'Stream') tracker.streamNamespaces.add(localName) + if (importedName === 'Metric') tracker.metricNamespaces.add(localName) + if ( + rawEffectMembers.has(importedName) === true || + rawStreamMembers.has(importedName) === true || + rawMetricMembers.has(importedName) === true + ) { + tracker.directRawCalls.add(localName) + } + } +} + +const importSpecifierImportedName = (specifier: any): string | undefined => { + const imported = specifier.imported + if (imported?.type === 'Identifier') return imported.name + if (imported?.type === 'Literal' && typeof imported.value === 'string') return imported.value + return undefined +} + +const rawOtelCallSource = (tracker: EffectImportTracker, node: any): string | undefined => { + const callee = node.callee + + if (callee?.type === 'Identifier' && tracker.directRawCalls.has(callee.name) === true) { + if (callee.name === 'annotateCurrentSpan') return 'Effect.annotateCurrentSpan()' + if (rawMetricMembers.has(callee.name) === true) return `Metric.${callee.name}()` + return 'Effect.withSpan() / Stream.withSpan()' + } + + if (callee?.type !== 'MemberExpression' || callee.computed === true) return undefined + + const propertyName = callee.property?.name + if (typeof propertyName !== 'string') return undefined + + const object = callee.object + if (object?.type === 'Identifier') { + if ( + tracker.effectNamespaces.has(object.name) === true && + rawEffectMembers.has(propertyName) === true + ) { + return `Effect.${propertyName}()` + } + + if ( + tracker.streamNamespaces.has(object.name) === true && + rawStreamMembers.has(propertyName) === true + ) { + return `Stream.${propertyName}()` + } + + if ( + tracker.metricNamespaces.has(object.name) === true && + rawMetricMembers.has(propertyName) === true + ) { + return `Metric.${propertyName}()` + } + } + + const namespaceCall = rawOtelNamespaceCallSource(tracker, callee) + if (namespaceCall !== undefined) return namespaceCall + + return undefined +} + +const rawOtelNamespaceCallSource = ( + tracker: EffectImportTracker, + callee: any, +): string | undefined => { + const namespaceMember = callee.object + if (namespaceMember?.type !== 'MemberExpression' || namespaceMember.computed === true) { + return undefined + } + + const root = namespaceMember.object + if (root?.type !== 'Identifier') return undefined + if (tracker.effectModuleNamespaces.has(root.name) === false) return undefined + + const namespaceName = namespaceMember.property?.name + const propertyName = callee.property?.name + if (namespaceName === 'Effect' && rawEffectMembers.has(propertyName) === true) { + return `Effect.${propertyName}()` + } + if (namespaceName === 'Stream' && rawStreamMembers.has(propertyName) === true) { + return `Stream.${propertyName}()` + } + if (namespaceName === 'Metric' && rawMetricMembers.has(propertyName) === true) { + return `Metric.${propertyName}()` + } + + return undefined +} + +/** ESLint rule banning direct raw Effect OTEL span primitives outside approved boundaries. */ +export const noRawOtelPrimitivesRule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Ban raw Effect/Stream/Metric OpenTelemetry primitives outside schema-backed OTEL contract boundaries', + recommended: false, + }, + messages: { + rawOtelPrimitive: + 'Raw OTEL primitive `{{source}}` bypasses the schema-first telemetry contract. Define an `OtelOperation`/`OtelSpan`/`OtelMetric` contract in package observability code and use that instead.', + }, + schema: [], + }, + defaultOptions: [], + create(context: any) { + const tracker = createTracker() + + return { + ImportDeclaration(node: any) { + trackEffectImport(tracker, node) + }, + + CallExpression(node: any) { + const source = rawOtelCallSource(tracker, node) + if (source === undefined) return + + context.report({ + node, + messageId: 'rawOtelPrimitive', + data: { source }, + }) + }, + } + }, +} diff --git a/packages/@overeng/oxc-config/src/no-raw-otel-primitives.unit.test.ts b/packages/@overeng/oxc-config/src/no-raw-otel-primitives.unit.test.ts new file mode 100644 index 000000000..9c85bf687 --- /dev/null +++ b/packages/@overeng/oxc-config/src/no-raw-otel-primitives.unit.test.ts @@ -0,0 +1,203 @@ +import tsParser from '@typescript-eslint/parser' +import { RuleTester } from '@typescript-eslint/rule-tester' +import { RuleTester as ESLintRuleTester } from 'eslint' +import { afterAll, describe, it } from 'vitest' + +import plugin from './mod.ts' + +RuleTester.afterAll = afterAll +RuleTester.describe = describe +RuleTester.it = it +ESLintRuleTester.describe = describe +ESLintRuleTester.it = it + +const ruleTester = new ESLintRuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}) + +const tsRuleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parser: tsParser, + }, +}) + +const rule = plugin.rules['no-raw-otel-primitives'] + +/** The exact rendered message for a flagged `Effect.withSpan`. */ +const effectWithSpanMessage = + 'Raw OTEL primitive `Effect.withSpan()` bypasses the schema-first telemetry contract. Define an `OtelOperation`/`OtelSpan`/`OtelMetric` contract in package observability code and use that instead.' + +ruleTester.run('no-raw-otel-primitives: valid contract usage and unrelated calls', rule, { + valid: [ + { + code: `import { Effect } from 'effect' +const value = Effect.gen(function* () { return 1 })`, + }, + { + code: `import { Stream } from 'effect' +const value = Stream.map(stream, (x) => x)`, + }, + { + code: `import { Effect } from 'other' +const value = Effect.withSpan('not-effect')`, + }, + { + code: `const Effect = { withSpan: () => undefined } +Effect.withSpan('local')`, + }, + { + code: `import { OtelOperation } from '@overeng/otel-contract' +const Operation = OtelOperation.define({ name: 'x', schema, label: () => 'x' }) +effect.pipe(Operation.with({ label: 'x' }))`, + }, + { + code: `import { OtelMetric } from '@overeng/otel-contract' +const Invocations = OtelMetric.counter({ name: 'invocations_total', labels })`, + }, + ], + invalid: [], +}) + +ruleTester.run('no-raw-otel-primitives: invalid named imports', rule, { + valid: [], + invalid: [ + { + code: `import { Effect } from 'effect' +const program = effect.pipe(Effect.withSpan('raw'))`, + errors: [{ message: effectWithSpanMessage }], + }, + { + code: `import { Effect } from 'effect' +const program = Effect.annotateCurrentSpan('span.label', 'raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { Stream } from 'effect' +const program = stream.pipe(Stream.withSpan('raw'))`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + ], +}) + +ruleTester.run('no-raw-otel-primitives: invalid aliases and namespace imports', rule, { + valid: [], + invalid: [ + { + code: `import { Effect as E } from 'effect' +const program = E.withSpan('raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { Stream as S } from 'effect' +const program = S.withSpan('raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import * as EffectLib from 'effect' +const program = EffectLib.Effect.withSpan('raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import * as EffectLib from 'effect' +const program = EffectLib.Stream.withSpan('raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import * as EffectLib from 'effect' +const program = EffectLib.Effect.annotateCurrentSpan('span.label', 'raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import * as EffectLib from 'effect' +const metric = EffectLib.Metric.counter('raw_total')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + ], +}) + +ruleTester.run('no-raw-otel-primitives: invalid direct raw imports', rule, { + valid: [], + invalid: [ + { + code: `import { withSpan } from 'effect' +const program = withSpan('raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { withSpan as rawWithSpan } from 'effect' +const program = rawWithSpan('raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { annotateCurrentSpan as annotate } from 'effect' +annotate('span.label', 'raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { counter } from 'effect' +const metric = counter('raw_total')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + ], +}) + +ruleTester.run('no-raw-otel-primitives: invalid raw Metric APIs', rule, { + valid: [], + invalid: [ + { + code: `import { Metric } from 'effect' +const metric = Metric.counter('raw_total')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { Metric as M } from 'effect' +const metric = M.histogram('raw_ms', boundaries)`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { Metric } from 'effect' +const tagged = Metric.tagged(metric, 'service', 'api')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { Metric } from 'effect' +const program = Metric.increment(metric)`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { Metric } from 'effect' +const program = Metric.incrementBy(metric, 2)`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + { + code: `import { Metric } from 'effect' +const program = Metric.update(metric, 42)`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + ], +}) + +tsRuleTester.run('no-raw-otel-primitives: TypeScript', rule, { + valid: [ + { + code: `import { Effect } from 'effect' +const program: Effect.Effect = Effect.succeed(1)`, + }, + { + code: `import type { Effect } from 'effect' +type Program = Effect.Effect`, + }, + ], + invalid: [ + { + code: `import { Effect as E } from 'effect' +const program = E.withSpan('raw')`, + errors: [{ messageId: 'rawOtelPrimitive' }], + }, + ], +}) diff --git a/packages/@overeng/pty-effect/package.json b/packages/@overeng/pty-effect/package.json index cbeb1d913..6f7b8f987 100644 --- a/packages/@overeng/pty-effect/package.json +++ b/packages/@overeng/pty-effect/package.json @@ -15,7 +15,8 @@ } }, "dependencies": { - "@myobie/pty": "0.9.0" + "@myobie/pty": "0.9.0", + "@overeng/otel-contract": "workspace:^" }, "devDependencies": { "@effect/vitest": "0.29.0", @@ -32,6 +33,7 @@ "source": "package.json.genie.ts", "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ + "packages/@overeng/otel-contract", "packages/@overeng/pty-effect", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/pty-effect/package.json.genie.ts b/packages/@overeng/pty-effect/package.json.genie.ts index ce010d113..400668515 100644 --- a/packages/@overeng/pty-effect/package.json.genie.ts +++ b/packages/@overeng/pty-effect/package.json.genie.ts @@ -5,6 +5,7 @@ import { privatePackageDefaults, type PackageJsonData, } from '../../../genie/internal.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' const peerDepNames = ['effect'] as const @@ -12,6 +13,7 @@ const peerDepNames = ['effect'] as const const workspaceDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/pty-effect' }), dependencies: { + workspace: [otelContractPkg], external: catalog.pick('@myobie/pty'), }, devDependencies: { diff --git a/packages/@overeng/pty-effect/src/PtySession.ts b/packages/@overeng/pty-effect/src/PtySession.ts index cd605c450..04b11df8a 100644 --- a/packages/@overeng/pty-effect/src/PtySession.ts +++ b/packages/@overeng/pty-effect/src/PtySession.ts @@ -1,7 +1,14 @@ import { Session as UpstreamSession } from '@myobie/pty/testing' -import { Effect, Option, Predicate, Schedule, Stream, pipe } from 'effect' +import { Effect, Option, Predicate, Schedule, Schema, Stream, pipe } from 'effect' import type { Scope } from 'effect' +import { + OtelAttr, + OtelOperation, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + import { PtyError } from './PtyError.ts' import type { Key } from './PtyKey.ts' import type { PtySpec, TerminalSize } from './PtySpec.ts' @@ -61,6 +68,28 @@ export interface PtySession { /** Default polling schedule for `waitFor*` (50ms fixed). */ export const defaultPollSchedule: Schedule.Schedule = Schedule.spaced('50 millis') +const PtySessionMakeOperation = OtelOperation.define({ + name: 'pty-session.make', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + mode: Schema.Literal('Spawn', 'Server').pipe(OtelAttr.key({ key: 'pty.session.mode' })), + }), + label: ({ label }) => label, +}) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + interface WrapSyncOpts { readonly method: string readonly reason?: PtyError['reason'] @@ -273,4 +302,4 @@ export const make = (spec: PtySpec): Effect.Effect + Effect.gen(function* () { + for (let attempt = 0; attempt < 40; attempt++) { + const screen = yield* client.peek({ name: input.name, plain: true }) + if (screen.includes(input.needle) === true) return screen + yield* Effect.sleep('50 millis') + } + return yield* client.peek({ name: input.name, plain: true }) + }) + describe('PtyClient', () => { it('keeps @overeng/pty-effect/client compile-safe for Bun-built CLIs', () => { withTempDir('pty-effect-bun-compile-', (dir) => { @@ -155,9 +168,7 @@ describe('PtyClient', () => { args: ['-c', 'echo PEEK_TARGET && sleep 0.5'], }) - yield* Effect.sleep('100 millis') - - const screen = yield* client.peek({ name, plain: true }) + const screen = yield* waitForPeekText(client, { name, needle: 'PEEK_TARGET' }) expect(screen).toContain('PEEK_TARGET') }).pipe(Effect.provide(ptyClientLayer)), ), diff --git a/packages/@overeng/pty-effect/src/client.ts b/packages/@overeng/pty-effect/src/client.ts index 38f5a9cc3..e234e6869 100644 --- a/packages/@overeng/pty-effect/src/client.ts +++ b/packages/@overeng/pty-effect/src/client.ts @@ -43,6 +43,13 @@ import { } from 'effect' import type { Scope } from 'effect' +import { + OtelAttr, + OtelOperation, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + import { PtyError } from './PtyError.ts' import { decodePtyEvent, type PtyEvent } from './PtyEvent.ts' import { PtyName, type TerminalSize } from './PtySpec.ts' @@ -52,6 +59,74 @@ import type { Screenshot } from './Screenshot.ts' const PtyTags = Schema.Record({ key: Schema.String, value: Schema.String }) +const PtyClientNameOperation = (spanName: string) => + OtelOperation.define({ + name: spanName, + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + name: Schema.String.pipe(OtelAttr.key({ key: 'pty.name' })), + }), + label: ({ label }) => label, + }) + +const PtyClientOperation = (spanName: string) => + OtelOperation.define({ + name: spanName, + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + }), + label: ({ label }) => label, + }) + +const PtyClientWaitOperation = (spanName: string) => + OtelOperation.define({ + name: spanName, + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + name: Schema.String.pipe(OtelAttr.key({ key: 'pty.name' })), + needle: Schema.String.pipe(OtelAttr.key({ key: 'pty.wait.needle' })), + }), + label: ({ label }) => label, + }) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + +const withPtyNameSpan = + (spanName: string, name: string) => + (effect: Effect.Effect) => + effect.pipe(trustedWith(PtyClientNameOperation(spanName), { label: name, name })) + +const withPtyOperationSpan = + (spanName: string, label: string) => + (effect: Effect.Effect) => + effect.pipe(trustedWith(PtyClientOperation(spanName), { label })) + +const withPtyWaitSpan = ( + spanName: string, + input: { readonly name: PtyName; readonly needle: string | RegExp }, +) => { + const needle = String(input.needle) + return (effect: Effect.Effect) => + effect.pipe( + trustedWith(PtyClientWaitOperation(spanName), { + label: `${input.name}: ${needle}`, + name: input.name, + needle, + }), + ) +} + /** * Spec for spawning a daemon-mode pty session via `@myobie/pty/client`'s * `spawnDaemon`. The daemon is detached and outlives the spawning process. @@ -387,7 +462,7 @@ const spawnDaemon = (spec: PtyDaemonSpec): Effect.Effect => if (spec.tags !== undefined && Object.keys(spec.tags).length > 0) { yield* waitForPersistedTags({ name: spec.name, tags: spec.tags }) } - }).pipe(Effect.withSpan('pty-client.spawnDaemon', { attributes: { 'span.label': spec.name } })) + }).pipe(withPtyNameSpan('pty-client.spawnDaemon', spec.name)) const peek = (input: { readonly name: PtyName @@ -404,13 +479,13 @@ const peek = (input: { ...(input.plain !== undefined ? { plain: input.plain } : {}), ...(input.full !== undefined ? { full: input.full } : {}), }), - }).pipe(Effect.withSpan('pty-client.peek', { attributes: { 'span.label': input.name } })) + }).pipe(withPtyNameSpan('pty-client.peek', input.name)) const list: Effect.Effect, PtyError> = wrapPromise({ method: 'list', reason: 'ConnectFailed', thunk: () => upstreamListSessions(), -}).pipe(Effect.withSpan('pty-client.list')) +}).pipe(withPtyOperationSpan('pty-client.list', 'list')) const get = ({ name }: PtyGetSessionSpec) => Effect.gen(function* () { @@ -421,20 +496,20 @@ const get = ({ name }: PtyGetSessionSpec) => name, thunk: () => upstreamGetSession(name), }) - }).pipe(Effect.withSpan('pty-client.getSession', { attributes: { 'span.label': name } })) + }).pipe(withPtyNameSpan('pty-client.getSession', name)) const exists = (input: { readonly name: PtyName }) => pipe( list, Effect.map((sessions) => sessions.some((session) => session.name === input.name)), - Effect.withSpan('pty-client.exists', { attributes: { 'span.label': input.name } }), + withPtyNameSpan('pty-client.exists', input.name), ) const gc: Effect.Effect, PtyError> = wrapPromise({ method: 'gc', reason: 'ConnectFailed', thunk: () => upstreamGc(), -}).pipe(Effect.withSpan('pty-client.gc')) +}).pipe(withPtyOperationSpan('pty-client.gc', 'gc')) const updateTags = (spec: PtyUpdateTagsSpec) => Effect.gen(function* () { @@ -445,7 +520,7 @@ const updateTags = (spec: PtyUpdateTagsSpec) => name: spec.name, thunk: () => upstreamUpdateTags(spec.name, { ...spec.tags }, spec.removals?.slice() ?? []), }) - }).pipe(Effect.withSpan('pty-client.updateTags', { attributes: { 'span.label': spec.name } })) + }).pipe(withPtyNameSpan('pty-client.updateTags', spec.name)) const sendData = (spec: PtySendDataSpec) => Effect.gen(function* () { @@ -461,7 +536,7 @@ const sendData = (spec: PtySendDataSpec) => ...(spec.delayMs !== undefined ? { delayMs: spec.delayMs } : {}), }), }) - }).pipe(Effect.withSpan('pty-client.sendData', { attributes: { 'span.label': spec.name } })) + }).pipe(withPtyNameSpan('pty-client.sendData', spec.name)) const queryStats = (spec: PtyQueryStatsSpec) => Effect.gen(function* () { @@ -472,7 +547,7 @@ const queryStats = (spec: PtyQueryStatsSpec) => name: spec.name, thunk: () => upstreamQueryStats(spec.name, spec.timeoutMs), }) - }).pipe(Effect.withSpan('pty-client.queryStats', { attributes: { 'span.label': spec.name } })) + }).pipe(withPtyNameSpan('pty-client.queryStats', spec.name)) const readRecentEvents = (spec: PtyReadRecentEventsSpec) => Effect.gen(function* () { @@ -487,9 +562,7 @@ const readRecentEvents = (spec: PtyReadRecentEventsSpec) => events, decodeEvent({ method: 'readRecentEvents', name: spec.name }), ) - }).pipe( - Effect.withSpan('pty-client.readRecentEvents', { attributes: { 'span.label': spec.name } }), - ) + }).pipe(withPtyNameSpan('pty-client.readRecentEvents', spec.name)) const followEvents = (spec: PtyFollowEventsSpec): Stream.Stream => Stream.asyncPush((emit) => @@ -519,7 +592,7 @@ const followEvents = (spec: PtyFollowEventsSpec): Stream.Stream Effect.sync(() => follower.stop()), ) - }).pipe(Effect.withSpan('pty-client.followEvents')), + }).pipe(withPtyOperationSpan('pty-client.followEvents', 'follow')), ) const kill = (input: { readonly name: PtyName }) => @@ -538,7 +611,7 @@ const kill = (input: { readonly name: PtyName }) => name: input.name, thunk: () => process.kill(session.pid!, 'SIGTERM'), }) - }).pipe(Effect.withSpan('pty-client.kill', { attributes: { 'span.label': input.name } })) + }).pipe(withPtyNameSpan('pty-client.kill', input.name)) const attach = (spec: PtyAttachSpec): Effect.Effect => Effect.gen(function* () { @@ -626,7 +699,7 @@ const attach = (spec: PtyAttachSpec): Effect.Effect pipe( @@ -648,9 +721,7 @@ const attach = (spec: PtyAttachSpec): Effect.Effect @@ -673,9 +744,7 @@ const attach = (spec: PtyAttachSpec): Effect.Effect ── run () (replay-aware) │ context.with(attemptContext) ▼ bridge: trace.getActiveSpan().spanContext() → Tracer.withSpanContext -Effect spans (Effect.withSpan on boundary ops) +Effect spans (schema-first Restate operation contracts) ``` ## Wiring it up @@ -102,8 +102,8 @@ on: `restate.error.class` (`terminal` | `retryable` | `cancelled`), read from the same `classifyOutcome` the SDK outcome is built on (so the span class matches exactly). -For custom **business** attributes, use `Restate.annotateSpan` in a handler — a thin -otel-free combinator over `Effect.annotateCurrentSpan` on the core namespace: +For custom **business** attributes, use `Restate.annotateSpan` in a handler — the +core, otel-free annotation API: ```ts import { Restate } from '@overeng/restate-effect' diff --git a/packages/@overeng/restate-effect/package.json b/packages/@overeng/restate-effect/package.json index 7802546de..b9f1c1f77 100644 --- a/packages/@overeng/restate-effect/package.json +++ b/packages/@overeng/restate-effect/package.json @@ -19,6 +19,7 @@ } }, "dependencies": { + "@overeng/otel-contract": "workspace:^", "@restatedev/restate-sdk": "1.14.5", "@restatedev/restate-sdk-clients": "1.14.5" }, @@ -59,6 +60,7 @@ "source": "package.json.genie.ts", "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ + "packages/@overeng/otel-contract", "packages/@overeng/restate-effect", "packages/@overeng/utils", "packages/@overeng/utils-dev" diff --git a/packages/@overeng/restate-effect/package.json.genie.ts b/packages/@overeng/restate-effect/package.json.genie.ts index db77ae750..37cb1fbae 100644 --- a/packages/@overeng/restate-effect/package.json.genie.ts +++ b/packages/@overeng/restate-effect/package.json.genie.ts @@ -6,6 +6,7 @@ import { privatePackageDefaults, type PackageJsonData, } from '../../../genie/internal.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' import utilsPkg from '../utils/package.json.genie.ts' @@ -30,6 +31,7 @@ const otelPeerDepNames = [ const workspaceDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/restate-effect' }), dependencies: { + workspace: [otelContractPkg], external: catalog.pick('@restatedev/restate-sdk', '@restatedev/restate-sdk-clients'), }, devDependencies: { diff --git a/packages/@overeng/restate-effect/src/authoring/RestateContext.ts b/packages/@overeng/restate-effect/src/authoring/RestateContext.ts index 5a67381d2..7e9664b18 100644 --- a/packages/@overeng/restate-effect/src/authoring/RestateContext.ts +++ b/packages/@overeng/restate-effect/src/authoring/RestateContext.ts @@ -4,6 +4,7 @@ import { Context, Effect, Option, Runtime, Schema } from 'effect' import * as SchemaAST from 'effect/SchemaAST' import { contractSerdeFactory, invocationIdempotencyKey } from '../clients/InvocationPolicy.ts' +import { withRestateOperation } from '../observability/effect.ts' import { emitAwakeableWait, emitDurableStep, monotonicMs } from '../observability/Metrics.ts' import { type RedactionCipher, RestateRedaction } from '../schema/Redaction.ts' import { RestateError } from '../schema/RestateError.ts' @@ -353,7 +354,7 @@ export const run = ( * so gating the counter on non-replay makes it exactly-once across attempts. */ yield* emitDurableStep(ctx, name) return result - }).pipe(Effect.withSpan('restate.run', { attributes: { 'span.label': name } })) + }).pipe(withRestateOperation('restate.run', name)) /** * Observe a `Restate.run`'s OBSERVED outcome as an `Exit` VALUE instead of failing @@ -393,7 +394,7 @@ export const sleep = (millis: number, name?: string): Effect.Effect ctx.sleep(millis, name), (cause) => new RestateError({ reason: 'SleepFailed', method: `sleep(${millis})`, cause }), ) - }).pipe(Effect.withSpan('restate.sleep', { attributes: { 'span.label': name ?? `${millis}ms` } })) + }).pipe(withRestateOperation('restate.sleep', name ?? `${millis}ms`)) /** A `sleep` descriptor for use inside `Restate.all`/`race`/`any`. */ export const sleepDescriptor = (millis: number, name?: string): Descriptor => @@ -421,7 +422,7 @@ export const timeout = ( .map((value?: A) => value), (cause) => new RestateError({ reason: 'RunFailed', method: `timeout(${millis})`, cause }), ) - }).pipe(Effect.withSpan('restate.timeout', { attributes: { 'span.label': `${millis}ms` } })) + }).pipe(withRestateOperation('restate.timeout', `${millis}ms`)) /** * Combine durable-op descriptors deterministically. Issues every descriptor @@ -449,7 +450,7 @@ const combineDescriptors = () => combine(descriptors.map((d) => d.issue(ctx, redaction))), (cause) => new RestateError({ reason: 'RunFailed', method: label, cause }), ) - }).pipe(Effect.withSpan(`restate.${label}`)) + }).pipe(withRestateOperation(`restate.${label}`, label)) /** Await all durable descriptors → result TUPLE (issued in source order). */ export const all = []>( @@ -515,7 +516,7 @@ const readState = ( ), Effect.orDie, ) - }).pipe(Effect.withSpan('restate.state.get', { attributes: { 'span.label': key } })) + }).pipe(withRestateOperation('restate.state.get', key)) const writeState = ( schemas: S, @@ -542,7 +543,7 @@ const writeState = ( Effect.orDie, ) objectCtx.set(key, encoded) - }).pipe(Effect.withSpan('restate.state.set', { attributes: { 'span.label': key } })) + }).pipe(withRestateOperation('restate.state.set', key)) /** * Build the typed State combinator family bound to a contract's `state` block. @@ -560,12 +561,12 @@ export const stateFor = (schemas: S) => Effect.gen(function* () { const ctx = yield* RestateContext ;(ctx as restate.ObjectContext).clear(key) - }).pipe(Effect.withSpan('restate.state.clear', { attributes: { 'span.label': key } })), + }).pipe(withRestateOperation('restate.state.clear', key)), clearAll: (): Effect.Effect => Effect.gen(function* () { const ctx = yield* RestateContext ;(ctx as restate.ObjectContext).clearAll() - }).pipe(Effect.withSpan('restate.state.clearAll')), + }).pipe(withRestateOperation('restate.state.clearAll', 'clearAll')), stateKeys: (): Effect.Effect, never, StateRead | RestateContext> => Effect.gen(function* () { const ctx = yield* RestateContext @@ -575,7 +576,7 @@ export const stateFor = (schemas: S) => catch: (cause) => new RestateError({ reason: 'RunFailed', method: 'State.stateKeys', cause }), }).pipe(Effect.orDie) - }).pipe(Effect.withSpan('restate.state.stateKeys')), + }).pipe(withRestateOperation('restate.state.stateKeys', 'stateKeys')), }) as const /* ── ObjectKey accessor (capability-gated) ───────────────────────────────── */ @@ -589,7 +590,7 @@ export const objectKey: Effect.Effect const ctx = yield* RestateContext return (ctx as restate.ObjectContext).key }, -).pipe(Effect.withSpan('restate.objectKey')) +).pipe(withRestateOperation('restate.objectKey', 'objectKey')) /* ── Durable promises (Workflow cross-handler signalling) ────────────────── */ /* @@ -624,7 +625,7 @@ const promiseGet = ( new RestateError({ reason: 'RunFailed', method: `DurablePromise.get(${name})`, cause }), 'terminal-reject', ) - }).pipe(Effect.withSpan('restate.promise.get', { attributes: { 'span.label': name } })) + }).pipe(withRestateOperation('restate.promise.get', name)) /* A non-blocking read (`undefined` if unresolved — never suspends), awaited through * {@link awaitDurable} so a `reject` classifies TERMINALLY like {@link promiseGet} @@ -641,7 +642,7 @@ const promisePeek = ( new RestateError({ reason: 'RunFailed', method: `DurablePromise.peek(${name})`, cause }), 'terminal-reject', ) - }).pipe(Effect.withSpan('restate.promise.peek', { attributes: { 'span.label': name } })) + }).pipe(withRestateOperation('restate.promise.peek', name)) const promiseResolve = ( name: string, @@ -658,7 +659,7 @@ const promiseResolve = ( catch: (cause) => new RestateError({ reason: 'RunFailed', method: `DurablePromise.resolve(${name})`, cause }), }).pipe(Effect.orDie) - }).pipe(Effect.withSpan('restate.promise.resolve', { attributes: { 'span.label': name } })) + }).pipe(withRestateOperation('restate.promise.resolve', name)) const promiseReject = ( name: string, @@ -675,7 +676,7 @@ const promiseReject = ( catch: (cause) => new RestateError({ reason: 'RunFailed', method: `DurablePromise.reject(${name})`, cause }), }).pipe(Effect.orDie) - }).pipe(Effect.withSpan('restate.promise.reject', { attributes: { 'span.label': name } })) + }).pipe(withRestateOperation('restate.promise.reject', name)) /** * Build the typed durable-promise combinator family for one payload Schema. Each @@ -753,14 +754,14 @@ export const makeAwakeable = ( * resolution, never on replay. */ yield* emitAwakeableWait(ctx, monotonicMs() - startMs) return value - }).pipe(Effect.withSpan('restate.awakeable.await', { attributes: { 'span.label': aw.id } })) + }).pipe(withRestateOperation('restate.awakeable.await', aw.id)) /* The awakeable's completion promise is itself a `RestatePromise`, so it joins * the deterministic combinators like any other descriptor — issued in source * order, awaited once (decision 0005, #2). It is created ONCE (at `make`), so * `issue` just hands the existing promise to the combinator. */ const descriptor: Descriptor = { _tag: 'awakeable', issue: () => aw.promise } return { id: aw.id as AwakeableId, promise, descriptor } - }).pipe(Effect.withSpan('restate.awakeable.make')) + }).pipe(withRestateOperation('restate.awakeable.make', 'make')) /** Resolve an awakeable in-handler with a typed payload (encoded via `schema`). */ export const resolveAwakeable = ( @@ -771,7 +772,7 @@ export const resolveAwakeable = ( Effect.gen(function* () { const ctx = yield* RestateContext ctx.resolveAwakeable(id, payload, promiseSerde(schema)) - }).pipe(Effect.withSpan('restate.awakeable.resolve', { attributes: { 'span.label': id } })) + }).pipe(withRestateOperation('restate.awakeable.resolve', id)) /** Reject an awakeable in-handler with a reason (the awaiter fails terminally). */ export const rejectAwakeable = ( @@ -781,7 +782,7 @@ export const rejectAwakeable = ( Effect.gen(function* () { const ctx = yield* RestateContext ctx.rejectAwakeable(id, reason) - }).pipe(Effect.withSpan('restate.awakeable.reject', { attributes: { 'span.label': id } })) + }).pipe(withRestateOperation('restate.awakeable.reject', id)) /* ── In-handler service-to-service clients ───────────────────────────────── */ /* @@ -866,11 +867,7 @@ const callRpc = (opts: { }), 'terminal-reject', ) - }).pipe( - Effect.withSpan('restate.client.call', { - attributes: { 'span.label': `${opts.service}.${opts.handler}` }, - }), - ) + }).pipe(withRestateOperation('restate.client.call', `${opts.service}.${opts.handler}`)) const sendRpc = (opts: { readonly service: string @@ -905,11 +902,7 @@ const sendRpc = (opts: { cause, }), }) - }).pipe( - Effect.withSpan('restate.client.send', { - attributes: { 'span.label': `${opts.service}.${opts.handler}` }, - }), - ) + }).pipe(withRestateOperation('restate.client.send', `${opts.service}.${opts.handler}`)) /** * A `call` descriptor: issue an in-handler service-to-service `genericCall` as a diff --git a/packages/@overeng/restate-effect/src/mod.ts b/packages/@overeng/restate-effect/src/mod.ts index 2d9e89d07..d6656fc07 100644 --- a/packages/@overeng/restate-effect/src/mod.ts +++ b/packages/@overeng/restate-effect/src/mod.ts @@ -131,10 +131,11 @@ export const Restate = { /** * Stamp custom BUSINESS span attributes on the current span (R23, docs/vrs/08-observability/spec.md, decision * 0014) — the USER observability path for slicing in Tempo/Grafana (e.g. - * `dataSourceId`). A thin combinator over `Effect.annotateCurrentSpan`; otel-free - * (no `./otel` import). Use the `span.label` convention for a single primary - * label. Attributes are NOT replay-suppressed — for side-effecting telemetry use - * a metric / span event gated through `Restate.run`. + * `dataSourceId`). A dependency-light combinator over the package's schema-first + * OTEL contract helpers; no `./otel` import required. Use the `span.label` + * convention for a single primary label. Attributes are NOT replay-suppressed — + * for side-effecting telemetry use a metric / span event gated through + * `Restate.run`. */ annotateSpan, /** diff --git a/packages/@overeng/restate-effect/src/observability/Metrics.ts b/packages/@overeng/restate-effect/src/observability/Metrics.ts index eb4a7b73d..e819ac6c2 100644 --- a/packages/@overeng/restate-effect/src/observability/Metrics.ts +++ b/packages/@overeng/restate-effect/src/observability/Metrics.ts @@ -1,10 +1,10 @@ /** * The replay-aware baseline metrics (R23, docs/vrs/08-observability/spec.md, decision 0014). These are * Effect `Metric`s — `effect` is a CORE dependency, so the definitions live here - * with NO otel import; `./otel`'s `RestateOtel.layer` binds Effect's `Metric` to - * an OTel `MeterProvider` (`@effect/opentelemetry`'s `Metrics.layer`) so the same - * counters/histograms export over OTLP. Without that Layer the metrics are still - * valid Effect metrics (in-memory), so the core stays dependency-light. + * with no heavy OTel SDK import; `./otel`'s `RestateOtel.layer` binds Effect's + * `Metric` to an OTel `MeterProvider` (`@effect/opentelemetry`'s `Metrics.layer`) + * so the same counters/histograms export over OTLP. Without that Layer the metrics + * are still valid Effect metrics (in-memory), so the core stays dependency-light. * * The SUBTLE part is exactly-once-on-replay (R24, R25): an invocation re-runs its * handler on every attempt and on replay, so a naive increment double-counts. The @@ -15,23 +15,12 @@ * seam is exactly-once. */ import type * as restate from '@restatedev/restate-sdk' -import { Effect, Metric, MetricBoundaries, type Schema } from 'effect' -import type { MetricKeyType, MetricState } from 'effect' +import { Effect, type Schema } from 'effect' -import { findSensitiveFields } from '../schema/Redaction.ts' - -/** A histogram `Metric` over a `number` input (the shape `Metric.histogram` returns). */ -type HistogramMetric = Metric.Metric< - MetricKeyType.MetricKeyType.Histogram, - number, - MetricState.MetricState.Histogram -> +import { OtelMetric, OtelSpan } from '@overeng/otel-contract' -/* Duration histogram bucket boundaries (milliseconds) — a log-ish spread from - * sub-ms to ~5 min, covering fast handlers and long durable waits. */ -const durationBoundariesMs = MetricBoundaries.fromIterable([ - 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000, 30_000, 60_000, 300_000, -]) +import { findSensitiveFields } from '../schema/Redaction.ts' +import { RestateMetrics } from './contract.ts' /** * Per-invocation OUTCOME counter (`restate_invocations_total`), labelled @@ -41,16 +30,12 @@ const durationBoundariesMs = MetricBoundaries.fromIterable([ * NOT re-increment. A `retryable` attempt counts on every real failed attempt — * that is the retry-pressure signal (retries derive as the `retryable` rate). */ -export const invocationsTotal = Metric.counter('restate_invocations_total', { - description: 'Restate invocations by service, handler, and terminal outcome.', -}) +const invocationsTotalBridge = OtelMetric.effect.counter(RestateMetrics.invocationsTotal) +export const invocationsTotal = invocationsTotalBridge.metric /** Per-invocation DURATION histogram (`restate_invocation_duration_ms`), gated on non-replay. */ -export const invocationDurationMs = Metric.histogram( - 'restate_invocation_duration_ms', - durationBoundariesMs, - 'Wall-clock duration of a real invocation attempt (ms), by service/handler/outcome.', -) +const invocationDurationMsBridge = OtelMetric.effect.histogram(RestateMetrics.invocationDurationMs) +export const invocationDurationMs = invocationDurationMsBridge.metric /** * Per-ATTEMPT counter (`restate_attempts_total`), labelled `service` / `handler`. @@ -59,9 +44,8 @@ export const invocationDurationMs = Metric.histogram( * (the extra attempts are the retries). Gated on non-replay so a journal replay — * which re-runs the handler body without being a new attempt — is not counted. */ -export const attemptsTotal = Metric.counter('restate_attempts_total', { - description: 'Restate handler attempts by service and handler (drives the retry count).', -}) +const attemptsTotalBridge = OtelMetric.effect.counter(RestateMetrics.attemptsTotal) +export const attemptsTotal = attemptsTotalBridge.metric /** * Durable-step counter (`restate_durable_steps_total`), labelled `step` (the @@ -71,9 +55,8 @@ export const attemptsTotal = Metric.counter('restate_attempts_total', { * across all attempts. Not labelled by service/handler (the invocation span * already carries those) to keep cardinality bounded. */ -export const durableStepsTotal = Metric.counter('restate_durable_steps_total', { - description: 'Durable `Restate.run` steps executed (exactly-once across replays), by step name.', -}) +const durableStepsTotalBridge = OtelMetric.effect.counter(RestateMetrics.durableStepsTotal) +export const durableStepsTotal = durableStepsTotalBridge.metric /** * Awakeable wait-latency histogram (`restate_awakeable_wait_ms`). Emitted when an @@ -81,11 +64,8 @@ export const durableStepsTotal = Metric.counter('restate_durable_steps_total', { * non-replay (a replay reproduces the journaled completion instantly — not a real * wait). Captures external-completion latency (e.g. a webhook callback round-trip). */ -export const awakeableWaitMs = Metric.histogram( - 'restate_awakeable_wait_ms', - durationBoundariesMs, - 'Wall-clock wait for an awakeable to be externally resolved (ms).', -) +const awakeableWaitMsBridge = OtelMetric.effect.histogram(RestateMetrics.awakeableWaitMs) +export const awakeableWaitMs = awakeableWaitMsBridge.metric /** * `pollLoop` cycle counter (`restate_poll_loop_cycles_total`), labelled `name` @@ -93,9 +73,8 @@ export const awakeableWaitMs = Metric.histogram( * Emitted inside the loop's exclusive `cycle` handler gated on non-replay, so each * real cycle execution is counted exactly once. */ -export const pollLoopCyclesTotal = Metric.counter('restate_poll_loop_cycles_total', { - description: 'Scheduled `pollLoop` cycles executed, by loop name and cycle outcome.', -}) +const pollLoopCyclesTotalBridge = OtelMetric.effect.counter(RestateMetrics.pollLoopCyclesTotal) +export const pollLoopCyclesTotal = pollLoopCyclesTotalBridge.metric /** * A MONOTONIC wall-clock reading (milliseconds) for duration measurement. Uses @@ -130,26 +109,6 @@ export const emitWhenProcessing = ( emit: Effect.Effect, ): Effect.Effect => (isProcessing(ctx) === true ? emit : Effect.void) -/** Label a counter increment with the given tag pairs and emit it once. */ -const incrementTagged = ( - metric: Metric.Metric.Counter, - tags: ReadonlyArray, -): Effect.Effect => - Metric.increment( - tags.reduce>((m, [k, v]) => Metric.tagged(m, k, v), metric), - ) - -/** Record a value into a histogram labelled with the given tag pairs. */ -const recordTagged = ( - metric: HistogramMetric, - tags: ReadonlyArray, - value: number, -): Effect.Effect => - Metric.update( - tags.reduce((m, [k, v]) => Metric.tagged(m, k, v), metric), - value, - ) - /** * Emit the per-invocation outcome counter + duration histogram + the per-attempt * counter for ONE real attempt, gated on non-replay. `outcome` is the boundary's @@ -164,17 +123,23 @@ export const emitInvocationMetrics = ( readonly durationMs: number }, ): Effect.Effect => { - const labels = [ - ['service', args.service], - ['handler', args.handler], - ] as const - const outcomeLabels = [...labels, ['outcome', args.outcome]] as const return emitWhenProcessing( ctx, Effect.all( [ - incrementTagged(invocationsTotal, outcomeLabels), - recordTagged(invocationDurationMs, outcomeLabels, args.durationMs), + invocationsTotalBridge.trustedIncrement({ + service: args.service, + handler: args.handler, + outcome: args.outcome, + }), + invocationDurationMsBridge.trustedRecord( + { + service: args.service, + handler: args.handler, + outcome: args.outcome, + }, + args.durationMs, + ), ], { discard: true }, ), @@ -188,19 +153,24 @@ export const emitAttempt = ( ): Effect.Effect => emitWhenProcessing( ctx, - incrementTagged(attemptsTotal, [ - ['service', args.service], - ['handler', args.handler], - ]), + attemptsTotalBridge.trustedIncrement({ + service: args.service, + handler: args.handler, + }), ) /** Emit the durable-step counter for ONE real `Restate.run`, gated on non-replay. */ export const emitDurableStep = (ctx: restate.Context, step: string): Effect.Effect => - emitWhenProcessing(ctx, incrementTagged(durableStepsTotal, [['step', step]])) + emitWhenProcessing( + ctx, + durableStepsTotalBridge.trustedIncrement({ + step, + }), + ) /** Record an awakeable wait latency, gated on non-replay. */ export const emitAwakeableWait = (ctx: restate.Context, waitMs: number): Effect.Effect => - emitWhenProcessing(ctx, recordTagged(awakeableWaitMs, [], waitMs)) + emitWhenProcessing(ctx, awakeableWaitMsBridge.trustedRecord({}, waitMs)) /** Emit a `pollLoop` cycle outcome counter, gated on non-replay. */ export const emitPollLoopCycle = ( @@ -209,21 +179,25 @@ export const emitPollLoopCycle = ( ): Effect.Effect => emitWhenProcessing( ctx, - incrementTagged(pollLoopCyclesTotal, [ - ['name', args.name], - ['outcome', args.outcome], - ]), + pollLoopCyclesTotalBridge.trustedIncrement({ + name: args.name, + outcome: args.outcome, + }), ) -/** A span-attribute value (the shapes `Effect.annotateCurrentSpan` accepts). */ +/** A dynamic span-attribute value accepted by the contract's map annotation helper. */ type AttributeValue = string | number | boolean +const annotateDynamicSpanMap = ( + attributes: Readonly>, +): Effect.Effect => OtelSpan.annotateMap(attributes) + /** * Stamp custom BUSINESS attributes on the CURRENT span — the user path for - * slicing in Tempo/Grafana (R23, docs/vrs/08-observability/spec.md, decision 0014). A thin Effect combinator - * over `Effect.annotateCurrentSpan`: in a handler the current span is the Effect - * span reparented under the hook's `attempt ` span (the inbound bridge), - * so the attributes ride the one coherent trace. Exported as `Restate.annotateSpan`. + * slicing in Tempo/Grafana (R23, docs/vrs/08-observability/spec.md, decision 0014). In a handler + * the current span is the Effect span reparented under the hook's `attempt ` + * span (the inbound bridge), so the attributes ride the one coherent trace. Exported + * as `Restate.annotateSpan`. * * Use the `span.label` Grafana convention where it fits (a single primary label), * and plain keys for slicing dimensions (e.g. `dataSourceId`): @@ -246,14 +220,7 @@ type AttributeValue = string | number | boolean */ export const annotateSpan = ( attributes: Readonly>, -): Effect.Effect => - Effect.forEach( - Object.entries(attributes), - ([key, value]) => Effect.annotateCurrentSpan(key, value), - { - discard: true, - }, - ) +): Effect.Effect => annotateDynamicSpanMap(attributes) /** * Stamp span attributes PROJECTED from a decoded struct value — SAFE BY DEFAULT @@ -295,5 +262,5 @@ export const annotateSpanFrom = ( safe.push([key, v]) } } - return Effect.forEach(safe, ([key, v]) => Effect.annotateCurrentSpan(key, v), { discard: true }) + return annotateDynamicSpanMap(Object.fromEntries(safe)) } diff --git a/packages/@overeng/restate-effect/src/observability/contract.ts b/packages/@overeng/restate-effect/src/observability/contract.ts new file mode 100644 index 000000000..6b7d54180 --- /dev/null +++ b/packages/@overeng/restate-effect/src/observability/contract.ts @@ -0,0 +1,100 @@ +import { Schema } from 'effect' + +import { OtelAttr, OtelAttrs, OtelMetric, OtelOperation } from '@overeng/otel-contract' + +export const RestateOperationAttributes = Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), +}) + +export const restateOperation = (name: string) => + OtelOperation.define({ + name, + schema: RestateOperationAttributes, + label: ({ label }) => label, + }) + +export const BoundaryAttemptAttrs = OtelAttrs.defineSync( + Schema.Struct({ + service: OtelAttr.string('restate.service', { cardinality: 'bounded' }), + handler: OtelAttr.string('restate.handler', { cardinality: 'bounded' }), + objectKey: Schema.optional(OtelAttr.string('restate.object.key', { cardinality: 'high' })), + workflowId: Schema.optional(OtelAttr.string('restate.workflow.id', { cardinality: 'high' })), + idempotencyKey: Schema.optional( + OtelAttr.string('restate.idempotency.key', { cardinality: 'high' }), + ), + }), +) + +export const BoundaryOutcomeAttrs = OtelAttrs.defineSync( + Schema.Struct({ + errorClass: Schema.optional( + OtelAttr.literal('restate.error.class', 'terminal', 'retryable', 'cancelled'), + ), + errorTag: Schema.optional(OtelAttr.string('restate.error.tag', { cardinality: 'bounded' })), + }), +) + +const InvocationLabels = Schema.Struct({ + service: OtelAttr.string('service', { cardinality: 'bounded' }), + handler: OtelAttr.string('handler', { cardinality: 'bounded' }), + outcome: OtelAttr.literal('outcome', 'success', 'terminal', 'retryable', 'cancelled'), +}) + +const HandlerLabels = Schema.Struct({ + service: OtelAttr.string('service', { cardinality: 'bounded' }), + handler: OtelAttr.string('handler', { cardinality: 'bounded' }), +}) + +const DurableStepLabels = Schema.Struct({ + step: OtelAttr.string('step', { cardinality: 'bounded' }), +}) + +const NoLabels = Schema.Struct({}) + +const PollLoopCycleLabels = Schema.Struct({ + name: OtelAttr.string('name', { cardinality: 'bounded' }), + outcome: OtelAttr.literal('outcome', 'ok', 'error', 'stopped'), +}) + +export const RestateDurationMetricBoundariesMs = [ + 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000, 30_000, 60_000, 300_000, +] as const + +export const RestateMetrics = { + invocationsTotal: OtelMetric.counter({ + name: 'restate_invocations_total', + description: 'Restate invocations by service, handler, and terminal outcome.', + labels: InvocationLabels, + }), + invocationDurationMs: OtelMetric.histogram({ + name: 'restate_invocation_duration_ms', + description: + 'Wall-clock duration of a real invocation attempt (ms), by service/handler/outcome.', + unit: 'ms', + boundaries: RestateDurationMetricBoundariesMs, + labels: InvocationLabels, + }), + attemptsTotal: OtelMetric.counter({ + name: 'restate_attempts_total', + description: 'Restate handler attempts by service and handler (drives the retry count).', + labels: HandlerLabels, + }), + durableStepsTotal: OtelMetric.counter({ + name: 'restate_durable_steps_total', + description: + 'Durable `Restate.run` steps executed (exactly-once across replays), by step name.', + labels: DurableStepLabels, + }), + awakeableWaitMs: OtelMetric.histogram({ + name: 'restate_awakeable_wait_ms', + description: 'Wall-clock wait for an awakeable to be externally resolved (ms).', + unit: 'ms', + boundaries: RestateDurationMetricBoundariesMs, + labels: NoLabels, + }), + pollLoopCyclesTotal: OtelMetric.counter({ + name: 'restate_poll_loop_cycles_total', + description: 'Scheduled `pollLoop` cycles executed, by loop name and cycle outcome.', + labels: PollLoopCycleLabels, + }), +} as const diff --git a/packages/@overeng/restate-effect/src/observability/effect.ts b/packages/@overeng/restate-effect/src/observability/effect.ts new file mode 100644 index 000000000..c0ccc6c04 --- /dev/null +++ b/packages/@overeng/restate-effect/src/observability/effect.ts @@ -0,0 +1,15 @@ +import { Effect } from 'effect' + +import type { OtelAttrEncodeError } from '@overeng/otel-contract' + +import { restateOperation } from './contract.ts' + +export const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +export const withRestateOperation = + (name: string, label: string) => + (effect: Effect.Effect): Effect.Effect => + trustOtelContract(effect.pipe(restateOperation(name).with({ label }))) diff --git a/packages/@overeng/restate-effect/src/observability/observability.test.ts b/packages/@overeng/restate-effect/src/observability/observability.test.ts index 81599ead2..9ebe426a2 100644 --- a/packages/@overeng/restate-effect/src/observability/observability.test.ts +++ b/packages/@overeng/restate-effect/src/observability/observability.test.ts @@ -34,7 +34,15 @@ import { Effect, Layer, Schema } from 'effect' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Restate } from '../schema/Annotations.ts' -import { annotateSpanFrom, emitDurableStep, emitInvocationMetrics } from './Metrics.ts' +import { BoundaryAttemptAttrs, BoundaryOutcomeAttrs, RestateMetrics } from './contract.ts' +import { + annotateSpanFrom, + emitAttempt, + emitAwakeableWait, + emitDurableStep, + emitInvocationMetrics, + emitPollLoopCycle, +} from './Metrics.ts' import { RestateOtel } from './otel.ts' /* ── span-attribute test scaffolding (mirrors otel.test.ts) ────────────────── */ @@ -60,6 +68,73 @@ const makeHookCtx = (target: string) => { const fakeCtx = (isProcessing: boolean): RestateRawContext => ({ isProcessing: () => isProcessing }) as unknown as RestateRawContext +describe('schema-first observability contracts', () => { + it('encodes hook-owned attempt attrs and omits absent optional identity fields', () => { + expect( + BoundaryAttemptAttrs.encodeSync({ + service: 'Counter', + handler: 'bump', + objectKey: 'user-42', + }), + ).toEqual({ + 'restate.service': 'Counter', + 'restate.handler': 'bump', + 'restate.object.key': 'user-42', + }) + }) + + it('encodes boundary outcome attrs from the same terminal/retryable/cancelled domain', () => { + expect( + BoundaryOutcomeAttrs.encodeSync({ + errorClass: 'retryable', + errorTag: 'RateLimited', + }), + ).toEqual({ + 'restate.error.class': 'retryable', + 'restate.error.tag': 'RateLimited', + }) + }) + + it('declares low-cardinality metric-label contracts for baseline metrics', () => { + expect(RestateMetrics.invocationsTotal.metadata.labelKeys).toEqual([ + 'service', + 'handler', + 'outcome', + ]) + expect( + RestateMetrics.invocationsTotal.encodeLabelsSync({ + service: 'Counter', + handler: 'bump', + outcome: 'success', + }), + ).toEqual({ + service: 'Counter', + handler: 'bump', + outcome: 'success', + }) + }) + + it('declares every baseline metric through the schema-first metric contract', () => { + const contracts = [ + RestateMetrics.invocationsTotal, + RestateMetrics.invocationDurationMs, + RestateMetrics.attemptsTotal, + RestateMetrics.durableStepsTotal, + RestateMetrics.awakeableWaitMs, + RestateMetrics.pollLoopCyclesTotal, + ] + expect(contracts.map((contract) => contract.name)).toEqual([ + 'restate_invocations_total', + 'restate_invocation_duration_ms', + 'restate_attempts_total', + 'restate_durable_steps_total', + 'restate_awakeable_wait_ms', + 'restate_poll_loop_cycles_total', + ]) + expect(contracts.every((contract) => (contract.description ?? '').length > 0)).toBe(true) + }) +}) + describe('span attributes (server-free)', () => { let provider: NodeTracerProvider let exporter: InMemorySpanExporter @@ -266,6 +341,10 @@ describe('replay-aware baseline metrics', () => { const ctx = fakeCtx(true) await Effect.runPromise( Effect.all([ + emitAttempt(ctx, { + service: 'Counter', + handler: 'bump', + }), emitInvocationMetrics(ctx, { service: 'Counter', handler: 'bump', @@ -273,6 +352,8 @@ describe('replay-aware baseline metrics', () => { durationMs: 12, }), emitDurableStep(ctx, 'charge'), + emitAwakeableWait(ctx, 42), + emitPollLoopCycle(ctx, { name: 'invoice-poller', outcome: 'ok' }), ]), ) return reader.collect() @@ -290,6 +371,22 @@ describe('replay-aware baseline metrics', () => { const step = pointFor(metrics, 'restate_durable_steps_total', { step: 'charge' }) expect(step?.value).toBe(1) + const attempt = pointFor(metrics, 'restate_attempts_total', { + service: 'Counter', + handler: 'bump', + }) + expect(attempt?.value).toBe(1) + + const awakeable = pointFor(metrics, 'restate_awakeable_wait_ms', {}) + expect(awakeable).toBeDefined() + expect((awakeable!.value as { count: number }).count).toBeGreaterThanOrEqual(1) + + const pollLoop = pointFor(metrics, 'restate_poll_loop_cycles_total', { + name: 'invoice-poller', + outcome: 'ok', + }) + expect(pollLoop?.value).toBe(1) + /* The duration histogram recorded one sample for this label set. */ const duration = pointFor(metrics, 'restate_invocation_duration_ms', { service: 'Counter', diff --git a/packages/@overeng/restate-effect/src/observability/otel.ts b/packages/@overeng/restate-effect/src/observability/otel.ts index 7aab2f051..d742def11 100644 --- a/packages/@overeng/restate-effect/src/observability/otel.ts +++ b/packages/@overeng/restate-effect/src/observability/otel.ts @@ -58,6 +58,7 @@ import type { EndpointHooks, HandlerWrap, } from '../error/Boundary.ts' +import { BoundaryAttemptAttrs, BoundaryOutcomeAttrs } from './contract.ts' /** The OTel resource identity shared by the provider and the Effect tracer. */ export interface OtelResourceConfig { @@ -151,7 +152,8 @@ const acquireProvider = ( /** * The shared-provider scoped `Layer`. Builds + globally registers ONE * `TracerProvider` (+ global context manager) and binds Effect's tracer to that - * SAME provider, so `Effect.withSpan` and the Restate hook emit into one trace. + * SAME provider, so schema-first Restate operation spans and the Restate hook + * emit into one trace. * When a metric reader is configured it ALSO binds Effect's `Metric` to an OTel * `MeterProvider` SHARING the SAME `Resource` (decision 0014) — so the auto * baseline + user metrics export with the same `service.name`/identity as the @@ -267,9 +269,10 @@ const hook = (options?: Partial): EndpointHooks => * The inbound bridge (R23, docs/vrs/08-observability/spec.md) as a core {@link HandlerWrap}. Run INSIDE the * handler (the hook has set the attempt span active via `context.with`), it * reads `trace.getActiveSpan()?.spanContext()` and reparents the Effect program - * under it via `Tracer.withSpanContext` — so every in-handler `Effect.withSpan` - * becomes a child of the `attempt ` span. A no-op (program returned - * verbatim) when no span is active (e.g. the hook is not installed). + * under it via `Tracer.withSpanContext` — so every in-handler schema-first + * operation span becomes a child of the `attempt ` span. A no-op + * (program returned verbatim) when no span is active (e.g. the hook is not + * installed). */ const inboundBridge: HandlerWrap = (effect: Effect.Effect) => { const spanContext = trace.getActiveSpan()?.spanContext() @@ -323,22 +326,26 @@ const boundaryObserver: BoundaryObserver = (info) => { * stamps the SAME span at exit. */ const span: Span | undefined = trace.getActiveSpan() if (span === undefined) return () => {} - span.setAttribute('restate.service', info.service) - span.setAttribute('restate.handler', info.handler) - if (info.key !== undefined) span.setAttribute('restate.object.key', info.key) - if (info.workflowId !== undefined) span.setAttribute('restate.workflow.id', info.workflowId) - if (info.idempotencyKey !== undefined) { - span.setAttribute('restate.idempotency.key', info.idempotencyKey) - } + span.setAttributes( + BoundaryAttemptAttrs.encodeSync({ + service: info.service, + handler: info.handler, + ...(info.key === undefined ? {} : { objectKey: info.key }), + ...(info.workflowId === undefined ? {} : { workflowId: info.workflowId }), + ...(info.idempotencyKey === undefined ? {} : { idempotencyKey: info.idempotencyKey }), + }), + ) return (outcome) => { const errorClass = errorClassOf(outcome) - if (errorClass !== undefined) span.setAttribute('restate.error.class', errorClass) - if ( - (outcome._tag === 'terminal' || outcome._tag === 'retryable') && - outcome.errorTag !== undefined - ) { - span.setAttribute('restate.error.tag', outcome.errorTag) - } + span.setAttributes( + BoundaryOutcomeAttrs.encodeSync({ + ...(errorClass === undefined ? {} : { errorClass }), + ...((outcome._tag === 'terminal' || outcome._tag === 'retryable') && + outcome.errorTag !== undefined + ? { errorTag: outcome.errorTag } + : {}), + }), + ) } } diff --git a/packages/@overeng/restate-effect/src/runtime/Runtime.ts b/packages/@overeng/restate-effect/src/runtime/Runtime.ts index 541d82911..5039e1626 100644 --- a/packages/@overeng/restate-effect/src/runtime/Runtime.ts +++ b/packages/@overeng/restate-effect/src/runtime/Runtime.ts @@ -16,6 +16,7 @@ import * as restate from '@restatedev/restate-sdk' import { Chunk, Clock, Effect, Layer, Logger, LogLevel, Random } from 'effect' import { RestateContext } from '../authoring/RestateContext.ts' +import { withRestateOperation } from '../observability/effect.ts' /** * Build an Effect `Clock` backed by the invocation's journaled `ctx.date`. @@ -218,7 +219,7 @@ export const withAttemptInterruption = ( return Effect.sync(() => signal.removeEventListener('abort', onAbort)) }) return Effect.raceFirst(effect, onAttemptComplete).pipe( - Effect.withSpan('restate.attemptInterruption'), + withRestateOperation('restate.attemptInterruption', 'attemptInterruption'), ) } @@ -235,7 +236,7 @@ export const cancel = (invocationId: string): Effect.Effect = Effect const ctx = yield* RestateContext const ctxInternal = ctx as restate.internal.ContextInternal yield* Effect.promise(() => ctxInternal.cancellation()) -}).pipe(Effect.withSpan('restate.onCancellation')) +}).pipe(withRestateOperation('restate.onCancellation', 'cancellation')) diff --git a/packages/@overeng/restate-effect/src/scheduling/Reschedule.ts b/packages/@overeng/restate-effect/src/scheduling/Reschedule.ts index 5cf391201..7149213c3 100644 --- a/packages/@overeng/restate-effect/src/scheduling/Reschedule.ts +++ b/packages/@overeng/restate-effect/src/scheduling/Reschedule.ts @@ -23,6 +23,7 @@ import { objectKey } from '../authoring/RestateContext.ts' import type { ObjectKey, RestateContext } from '../authoring/RestateContext.ts' import type { ObjectContract, ObjectInputOf, ObjectMethodsOf } from '../authoring/Service.ts' import { sendObject } from '../clients/Client.ts' +import { withRestateOperation } from '../observability/effect.ts' import type { RestateError } from '../schema/RestateError.ts' /** @@ -62,4 +63,4 @@ export const reschedule = < yield* sendObject(opts.contract, key, opts.method, opts.input, { delayMillis: opts.delayMillis, }) - }).pipe(Effect.withSpan('restate.reschedule')) + }).pipe(withRestateOperation('restate.reschedule', String(opts.method))) diff --git a/packages/@overeng/restate-effect/tsconfig.json b/packages/@overeng/restate-effect/tsconfig.json index eee2ded5b..d8260401f 100644 --- a/packages/@overeng/restate-effect/tsconfig.json +++ b/packages/@overeng/restate-effect/tsconfig.json @@ -44,6 +44,9 @@ }, "include": ["src/**/*", "examples/**/*"], "references": [ + { + "path": "../otel-contract" + }, { "path": "../utils" }, diff --git a/packages/@overeng/restate-effect/tsconfig.json.genie.ts b/packages/@overeng/restate-effect/tsconfig.json.genie.ts index 396d72474..ef1ac06c1 100644 --- a/packages/@overeng/restate-effect/tsconfig.json.genie.ts +++ b/packages/@overeng/restate-effect/tsconfig.json.genie.ts @@ -10,5 +10,5 @@ export default tsconfigJson({ ...packageTsconfigCompilerOptions, }, include: ['src/**/*', 'examples/**/*'], - references: [{ path: '../utils' }, { path: '../utils-dev' }], + references: [{ path: '../otel-contract' }, { path: '../utils' }, { path: '../utils-dev' }], } satisfies TSConfigArgs) diff --git a/packages/@overeng/tui-react/package.json b/packages/@overeng/tui-react/package.json index 8b8460354..d864ab503 100644 --- a/packages/@overeng/tui-react/package.json +++ b/packages/@overeng/tui-react/package.json @@ -91,6 +91,7 @@ "source": "package.json.genie.ts", "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ + "packages/@overeng/otel-contract", "packages/@overeng/tui-core", "packages/@overeng/tui-react", "packages/@overeng/utils", diff --git a/packages/@overeng/tui-stories/nix/build.nix b/packages/@overeng/tui-stories/nix/build.nix index f63ca4b63..afd32a9e9 100644 --- a/packages/@overeng/tui-stories/nix/build.nix +++ b/packages/@overeng/tui-stories/nix/build.nix @@ -21,7 +21,7 @@ let # Managed by the repo FOD refresh workflow — do not edit manually. depsBuilds = { "." = { - hash = "sha256-OOiJGn1yN/v4xyfUyZkqJmkJPCmV4RGBLqvupUC1Bag="; + hash = "sha256-g+iqVtWaBhbgnNkFW2QhDOmZJKoiB7K9YSoW/A1Ok5I="; }; }; nativeNodePackages = [ opentuiCoreNative ]; diff --git a/packages/@overeng/tui-stories/package.json b/packages/@overeng/tui-stories/package.json index 8b2c0b42f..2651d6e74 100644 --- a/packages/@overeng/tui-stories/package.json +++ b/packages/@overeng/tui-stories/package.json @@ -92,6 +92,7 @@ "packages/@overeng/kdl", "packages/@overeng/kdl-effect", "packages/@overeng/megarepo", + "packages/@overeng/otel-contract", "packages/@overeng/tui-core", "packages/@overeng/tui-react", "packages/@overeng/tui-stories", diff --git a/packages/@overeng/utils-dev/src/otelite/Otelite.ts b/packages/@overeng/utils-dev/src/otelite/Otelite.ts index b9837e1c1..5338b2032 100644 --- a/packages/@overeng/utils-dev/src/otelite/Otelite.ts +++ b/packages/@overeng/utils-dev/src/otelite/Otelite.ts @@ -8,6 +8,12 @@ import { OteliteDecodeError, OteliteSpawnError, } from './errors.ts' +import { + withOteliteExecSpan, + withOteliteInspectSpan, + withOteliteInspectSummarySpan, + withOteliteLabelSpan, +} from './otel.ts' import { EndpointsEvent, LogRow, @@ -136,8 +142,9 @@ const summaryKind = { * tagged errors. The CLI's JSON output is the single source of truth — this * service never reimplements capture/inspect logic. * - * The `otelite` binary is resolved from `PATH`. Tests put the nix-built binary - * on `PATH` (see the package README). + * The `otelite` binary is resolved from `OTELITE_BIN` first, then `PATH`. + * Tests normally get the nix-built binary from the devenv `PATH`; raw-shell + * runs can set `OTELITE_BIN="$(nix build --no-link --print-out-paths .#otelite)/bin/otelite"`. */ export class Otelite extends Effect.Service()('@overeng/utils-dev/otelite/Otelite', { accessors: true, @@ -145,7 +152,7 @@ export class Otelite extends Effect.Service()('@overeng/utils-dev/oteli const executor = yield* CommandExecutor.CommandExecutor const fs = yield* FileSystem.FileSystem - const binary = 'otelite' + const binary = process.env.OTELITE_BIN ?? 'otelite' /** * Run otelite and collect its stdout + exit code. Spawn failures become @@ -167,7 +174,7 @@ export class Otelite extends Effect.Service()('@overeng/utils-dev/oteli }), ).pipe( Effect.mapError((cause) => new OteliteSpawnError({ argv: [binary, ...args], cause })), - Effect.withSpan('otelite.exec', { attributes: { 'otelite.argv': args } }), + withOteliteExecSpan(args), ) /** @@ -227,7 +234,7 @@ export class Otelite extends Effect.Service()('@overeng/utils-dev/oteli } return summary - }).pipe(Effect.withSpan('otelite.run')) + }).pipe(withOteliteLabelSpan('otelite.run')) const runCli = ( args: ReadonlyArray, @@ -282,7 +289,7 @@ export class Otelite extends Effect.Service()('@overeng/utils-dev/oteli Schema.decodeUnknown(Schema.parseJson(schema))(stdout).pipe( Effect.mapError((cause) => new OteliteDecodeError({ kind, raw: stdout, cause })), ), - ).pipe(Effect.withSpan('otelite.inspect.summary', { attributes: { signal } })) + ).pipe(withOteliteInspectSummarySpan(signal)) } const schema = rowSchema[signal] const kind = rowKind[signal] @@ -295,7 +302,7 @@ export class Otelite extends Effect.Service()('@overeng/utils-dev/oteli Effect.mapError((cause) => new OteliteDecodeError({ kind, raw: line, cause })), ), ), - ).pipe(Effect.withSpan('otelite.inspect', { attributes: { signal } })) + ).pipe(withOteliteInspectSpan(signal)) } /** @@ -488,12 +495,12 @@ export class Otelite extends Effect.Service()('@overeng/utils-dev/oteli inspect: handleInspect, summary: Deferred.await(summaryDeferred), } satisfies CaptureHandle - }).pipe(Effect.withSpan('otelite.capture')) + }).pipe(withOteliteLabelSpan('otelite.capture')) /** otelite's own version string (`otelite --version`). */ const version = Effect.suspend(() => runCli(['--version'], (stdout) => Effect.succeed(stdout.trim())), - ).pipe(Effect.withSpan('otelite.version')) + ).pipe(withOteliteLabelSpan('otelite.version')) return { run, capture, inspect, version } as const }), diff --git a/packages/@overeng/utils-dev/src/otelite/mod.ts b/packages/@overeng/utils-dev/src/otelite/mod.ts index 8d8b287c1..05089805c 100644 --- a/packages/@overeng/utils-dev/src/otelite/mod.ts +++ b/packages/@overeng/utils-dev/src/otelite/mod.ts @@ -9,7 +9,7 @@ * * Requires a `CommandExecutor` + `FileSystem` in context (e.g. * `NodeContext.layer` from `@effect/platform-node`) and the `otelite` binary on - * `PATH`. + * `PATH`, or an absolute binary path in `OTELITE_BIN`. */ export { Otelite } from './Otelite.ts' export type { CaptureHandle, CaptureOptions, RunOptions, Signal } from './Otelite.ts' @@ -55,5 +55,29 @@ export type { AttrMatcher, AttrPredicate, AttrPrimitive, + ContractAttributesSelector, + ContractSpanSelector, + OtelAttrsContract, + OtelSpanContract, SpanSelector, } from './trace-expect.ts' +export { + expectLogs, + expectMetrics, + LogExpect, + metricValue, + MetricExpect, + telemetryAttr, + TelemetryExpectError, +} from './signal-expect.ts' +export type { + ContractMetricSelector, + LogSelector, + MetricSelector, + MetricValueMatcher, + OtelMetricLabelsContract, + TelemetryAttrExpectations, + TelemetryAttrMatcher, + TelemetryAttrPredicate, + TelemetryAttrPrimitive, +} from './signal-expect.ts' diff --git a/packages/@overeng/utils-dev/src/otelite/otel.ts b/packages/@overeng/utils-dev/src/otelite/otel.ts new file mode 100644 index 000000000..fa8fbfcbb --- /dev/null +++ b/packages/@overeng/utils-dev/src/otelite/otel.ts @@ -0,0 +1,91 @@ +import { Effect, Schema } from 'effect' + +import type { Signal } from './Otelite.ts' + +type OtelAttributeValue = string | number | boolean + +type OtelAttributeMap = Readonly> + +const OteliteLabelAttrs = Schema.Struct({ + label: Schema.NonEmptyString, +}) + +const OteliteExecAttrs = Schema.Struct({ + label: Schema.NonEmptyString, + argv: Schema.Array(Schema.String), +}) + +const OteliteSignalAttrs = Schema.Struct({ + label: Schema.NonEmptyString, + signal: Schema.Literal('traces', 'metrics', 'logs'), +}) + +const encodeLabelAttrs = Schema.decodeSync(OteliteLabelAttrs) +const encodeExecAttrs = Schema.decodeSync(OteliteExecAttrs) +const encodeSignalAttrs = Schema.decodeSync(OteliteSignalAttrs) + +const withSpan = + ({ + name, + attributes, + root, + }: { + readonly name: string + readonly attributes: OtelAttributeMap + readonly root?: boolean + }) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.withSpan(name, { attributes, ...(root === undefined ? {} : { root }) })) + +export const withOteliteExecSpan = (argv: ReadonlyArray) => + withSpan({ + name: 'otelite.exec', + attributes: (() => { + const value = encodeExecAttrs({ label: argv[0] ?? 'exec', argv }) + return { + 'span.label': value.label, + 'otelite.argv': JSON.stringify(value.argv), + } + })(), + }) + +export const withOteliteLabelSpan = (name: string, label: string = name.replace('otelite.', '')) => + withSpan({ + name, + attributes: (() => { + const value = encodeLabelAttrs({ label }) + return { 'span.label': value.label } + })(), + }) + +export const withOteliteInspectSummarySpan = (signal: Signal) => + withSpan({ + name: 'otelite.inspect.summary', + attributes: (() => { + const value = encodeSignalAttrs({ label: signal, signal }) + return { 'span.label': value.label, signal: value.signal } + })(), + }) + +export const withOteliteInspectSpan = (signal: Signal) => + withSpan({ + name: 'otelite.inspect', + attributes: (() => { + const value = encodeSignalAttrs({ label: signal, signal }) + return { 'span.label': value.label, signal: value.signal } + })(), + }) + +export const withOteliteRootSpan = + ({ name, label }: { readonly name: string; readonly label: string }) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + withSpan({ + name, + root: true, + attributes: (() => { + const value = encodeLabelAttrs({ label }) + return { 'span.label': value.label } + })(), + }), + ) diff --git a/packages/@overeng/utils-dev/src/otelite/signal-expect.test.ts b/packages/@overeng/utils-dev/src/otelite/signal-expect.test.ts new file mode 100644 index 000000000..abca3035d --- /dev/null +++ b/packages/@overeng/utils-dev/src/otelite/signal-expect.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from '@effect/vitest' +import { Schema } from 'effect' + +import type { LogRow, MetricRow } from './schema.ts' +import { + expectLogs, + expectMetrics, + metricValue, + telemetryAttr, + TelemetryExpectError, +} from './signal-expect.ts' + +const metric = (overrides: Partial): MetricRow => ({ + schema: 'otelite.metric/v1', + service: 'restate-effect', + name: 'restate.invocations', + type: 'sum', + unit: '1', + value: 3, + time_unix_nano: '2', + start_time_unix_nano: '1', + temporality: 'cumulative', + monotonic: true, + attrs: { + handler: 'create', + outcome: 'success', + replay: 'false', + }, + ...overrides, +}) + +const log = (overrides: Partial): LogRow => ({ + schema: 'otelite.log/v1', + service: 'notion-md', + scope: 'effect', + body: 'page pulled', + severity_number: 9, + severity_text: 'INFO', + trace_id: 'trace-1', + span_id: 'span-1', + time_unix_nano: '3', + attrs: { + page_id: 'page-1', + cached: 'true', + }, + ...overrides, +}) + +describe('signal-expect', () => { + it('finds metrics by name, service, type, unit, value, and attrs', () => { + const metrics = expectMetrics([ + metric({}), + metric({ + name: 'restate.invocation.duration', + type: 'histogram', + unit: 'ms', + value: undefined, + attrs: { handler: 'create' }, + }), + ]) + + expect(metrics.metric('restate.invocations').expectOne({ value: 3 }).name).toBe( + 'restate.invocations', + ) + expect( + metrics.service('restate-effect').expectOne({ + type: 'sum', + unit: '1', + attrs: { + outcome: 'success', + replay: telemetryAttr.boolean(false), + }, + }).value, + ).toBe(3) + }) + + it('supports metric value predicates and schema-backed attr matchers', () => { + const metrics = expectMetrics([ + metric({ attrs: { outcome: 'success', payload: '{"ok":true}' } }), + ]) + + const row = metrics.expectOne({ + value: metricValue.predicate('greater than two', (actual) => actual > 2), + attrs: { + outcome: telemetryAttr.schema(Schema.Literal('success')), + payload: telemetryAttr.json(Schema.Struct({ ok: Schema.Boolean })), + }, + }) + + expect(row.name).toBe('restate.invocations') + }) + + it('matches structural metric-label contracts without requiring OtelMetric', () => { + const labels = { + unsafeEncode: (value: { readonly handler: string; readonly outcome: string }) => ({ + handler: value.handler, + outcome: value.outcome, + }), + } + const contract = { + name: 'restate.invocations', + unit: '1', + type: 'sum', + labels, + } + + const row = expectMetrics([metric({})]).expectMetric({ + metric: contract, + match: { handler: 'create', outcome: 'success' }, + selector: { value: metricValue.present() }, + }) + + expect(row.service).toBe('restate-effect') + }) + + it('finds logs by body, severity, trace linkage, and attrs', () => { + const logs = expectLogs([ + log({}), + log({ + body: 'page failed', + severity_text: 'ERROR', + severity_number: 17, + span_id: 'span-2', + attrs: { page_id: 'page-2', cached: 'false' }, + }), + ]) + + expect( + logs + .service('notion-md') + .severity('INFO') + .expectOne({ body: /pulled/ }).span_id, + ).toBe('span-1') + expect( + logs.expectOne({ + traceId: 'trace-1', + spanId: 'span-1', + attrs: { + cached: telemetryAttr.boolean(true), + page_id: telemetryAttr.present(), + }, + }).body, + ).toBe('page pulled') + }) + + it('throws runner-agnostic errors when signal expectations fail', () => { + const metrics = expectMetrics([metric({}), metric({ value: 5 })]) + const logs = expectLogs([log({})]) + + expect(() => metrics.expectOne({ name: 'restate.invocations' })).toThrow(TelemetryExpectError) + expect(() => metrics.expectSome({ attrs: { missing: telemetryAttr.present() } })).toThrow( + /Expected at least one metric/, + ) + expect(() => logs.expectOne({ severityText: 'ERROR' })).toThrow(/Expected exactly one log/) + }) +}) diff --git a/packages/@overeng/utils-dev/src/otelite/signal-expect.ts b/packages/@overeng/utils-dev/src/otelite/signal-expect.ts new file mode 100644 index 000000000..b6342cd2d --- /dev/null +++ b/packages/@overeng/utils-dev/src/otelite/signal-expect.ts @@ -0,0 +1,408 @@ +import { Either, Schema } from 'effect' + +import type { LogRow, MetricRow } from './schema.ts' + +export type TelemetryAttrPrimitive = string | number | boolean | null + +export type TelemetryAttrPredicate = (actual: string, row: Row) => boolean + +export type TelemetryAttrMatcher = + | TelemetryAttrPrimitive + | RegExp + | { + readonly _tag: 'Present' + } + | { + readonly _tag: 'Predicate' + readonly description: string + readonly predicate: TelemetryAttrPredicate + } + | { + readonly _tag: 'Schema' + readonly description: string + readonly matches: (actual: string) => boolean + } + +type StructuredTelemetryAttrMatcher = Exclude< + TelemetryAttrMatcher, + TelemetryAttrPrimitive | RegExp +> + +export type TelemetryAttrExpectations = Readonly>> + +export type MetricValueMatcher = + | number + | { + readonly _tag: 'Present' + } + | { + readonly _tag: 'Predicate' + readonly description: string + readonly predicate: (actual: number, row: MetricRow) => boolean + } + +export interface MetricSelector { + readonly name?: string + readonly service?: string + readonly type?: string + readonly unit?: string + readonly attrs?: TelemetryAttrExpectations + readonly value?: MetricValueMatcher +} + +export interface LogSelector { + readonly service?: string + readonly scope?: string | null + readonly body?: string | RegExp + readonly severityText?: string + readonly severityNumber?: number + readonly attrs?: TelemetryAttrExpectations + readonly traceId?: string | null + readonly spanId?: string | null +} + +/** Structural view of schema-backed metric labels, before OtelMetric exists. */ +export interface OtelMetricLabelsContract { + readonly unsafeEncode: (value: A) => Readonly> +} + +export interface ContractMetricSelector { + readonly metric: { + readonly name: string + readonly labels?: OtelMetricLabelsContract + readonly unit?: string + readonly type?: string + } + readonly match?: A + readonly selector?: Omit +} + +export class TelemetryExpectError extends Error { + readonly _tag = 'TelemetryExpectError' +} + +export const telemetryAttr = { + present: (): TelemetryAttrMatcher => ({ _tag: 'Present' }), + boolean: (expected: boolean): TelemetryAttrMatcher => String(expected), + int: (expected: number): TelemetryAttrMatcher => String(expected), + predicate: ( + description: string, + predicate: TelemetryAttrPredicate, + ): TelemetryAttrMatcher => ({ + _tag: 'Predicate', + description, + predicate, + }), + schema: ( + schema: Schema.Schema, + description = String(schema.ast.annotations.identifier ?? 'schema'), + ): TelemetryAttrMatcher => ({ + _tag: 'Schema', + description, + matches: (actual) => Either.isRight(Schema.decodeUnknownEither(schema)(actual)), + }), + json: ( + schema: Schema.Schema, + description = String(schema.ast.annotations.identifier ?? 'json schema'), + ): TelemetryAttrMatcher => ({ + _tag: 'Schema', + description, + matches: (actual) => + Either.isRight(Schema.decodeUnknownEither(Schema.parseJson(schema))(actual)), + }), +} as const + +export const metricValue = { + present: (): MetricValueMatcher => ({ _tag: 'Present' }), + predicate: ( + description: string, + predicate: (actual: number, row: MetricRow) => boolean, + ): MetricValueMatcher => ({ + _tag: 'Predicate', + description, + predicate, + }), +} as const + +export const expectMetrics = (metrics: readonly MetricRow[]) => MetricExpect.from(metrics) + +export const expectLogs = (logs: readonly LogRow[]) => LogExpect.from(logs) + +export class MetricExpect { + static from(metrics: readonly MetricRow[]): MetricExpect { + return new MetricExpect(metrics, []) + } + + private constructor( + readonly metrics: readonly MetricRow[], + private readonly filters: readonly string[], + ) {} + + metric(name: string): MetricExpect { + return this.filter({ name }) + } + + service(service: string): MetricExpect { + return this.filter({ service }) + } + + attrs(attrs: TelemetryAttrExpectations): MetricExpect { + return this.filter({ attrs }) + } + + filter(selector: MetricSelector): MetricExpect { + return new MetricExpect( + this.metrics.filter((metric) => matchesMetricSelector(metric, selector)), + [...this.filters, describeMetricSelector(selector)], + ) + } + + expectSome(selector: MetricSelector = {}): readonly MetricRow[] { + const matches = this.filter(selector).metrics + if (matches.length === 0) { + throw new TelemetryExpectError( + `Expected at least one metric matching ${this.describe(selector)}`, + ) + } + return matches + } + + expectOne(selector: MetricSelector = {}): MetricRow { + const matches = this.filter(selector).metrics + if (matches.length !== 1) { + throw new TelemetryExpectError( + `Expected exactly one metric matching ${this.describe(selector)}, found ${matches.length}`, + ) + } + return matches[0]! + } + + expectMetric(selector: ContractMetricSelector): MetricRow { + const contractSelector: MetricSelector = { + ...selector.selector, + name: selector.metric.name, + ...(selector.metric.unit === undefined ? {} : { unit: selector.metric.unit }), + ...(selector.metric.type === undefined ? {} : { type: selector.metric.type }), + ...(selector.metric.labels !== undefined && selector.match !== undefined + ? { attrs: contractAttrs(selector.metric.labels, selector.match) } + : {}), + } + return this.expectOne(contractSelector) + } + + private describe(selector: MetricSelector): string { + const parts = [...this.filters, describeMetricSelector(selector)].filter((part) => part !== '*') + return parts.length === 0 ? '*' : parts.join(' + ') + } +} + +export class LogExpect { + static from(logs: readonly LogRow[]): LogExpect { + return new LogExpect(logs, []) + } + + private constructor( + readonly logs: readonly LogRow[], + private readonly filters: readonly string[], + ) {} + + service(service: string): LogExpect { + return this.filter({ service }) + } + + severity(severityText: string): LogExpect { + return this.filter({ severityText }) + } + + attrs(attrs: TelemetryAttrExpectations): LogExpect { + return this.filter({ attrs }) + } + + filter(selector: LogSelector): LogExpect { + return new LogExpect( + this.logs.filter((log) => matchesLogSelector(log, selector)), + [...this.filters, describeLogSelector(selector)], + ) + } + + expectSome(selector: LogSelector = {}): readonly LogRow[] { + const matches = this.filter(selector).logs + if (matches.length === 0) { + throw new TelemetryExpectError( + `Expected at least one log matching ${this.describe(selector)}`, + ) + } + return matches + } + + expectOne(selector: LogSelector = {}): LogRow { + const matches = this.filter(selector).logs + if (matches.length !== 1) { + throw new TelemetryExpectError( + `Expected exactly one log matching ${this.describe(selector)}, found ${matches.length}`, + ) + } + return matches[0]! + } + + private describe(selector: LogSelector): string { + const parts = [...this.filters, describeLogSelector(selector)].filter((part) => part !== '*') + return parts.length === 0 ? '*' : parts.join(' + ') + } +} + +const matchesMetricSelector = (metric: MetricRow, selector: MetricSelector): boolean => { + if (selector.name !== undefined && metric.name !== selector.name) return false + if (selector.service !== undefined && metric.service !== selector.service) return false + if (selector.type !== undefined && metric.type !== selector.type) return false + if (selector.unit !== undefined && metric.unit !== selector.unit) return false + if (selector.value !== undefined && !matchesMetricValue(metric, selector.value)) return false + if (selector.attrs !== undefined && !matchesAttrs(metric, selector.attrs)) return false + return true +} + +const matchesLogSelector = (log: LogRow, selector: LogSelector): boolean => { + if (selector.service !== undefined && log.service !== selector.service) return false + if (selector.scope !== undefined && log.scope !== selector.scope) return false + if (selector.severityText !== undefined && log.severity_text !== selector.severityText) { + return false + } + if (selector.severityNumber !== undefined && log.severity_number !== selector.severityNumber) { + return false + } + if (selector.traceId !== undefined && log.trace_id !== selector.traceId) return false + if (selector.spanId !== undefined && log.span_id !== selector.spanId) return false + if (selector.body !== undefined) { + const bodyMatches = + selector.body instanceof RegExp ? selector.body.test(log.body) : log.body === selector.body + if (bodyMatches === false) return false + } + if (selector.attrs !== undefined && !matchesAttrs(log, selector.attrs)) return false + return true +} + +const matchesMetricValue = (metric: MetricRow, matcher: MetricValueMatcher): boolean => { + if (metric.value === undefined) return false + if (typeof matcher === 'number') return metric.value === matcher + switch (matcher._tag) { + case 'Present': + return true + case 'Predicate': + return matcher.predicate(metric.value, metric) + } +} + +const matchesAttrs = > }>( + row: Row, + attrs: TelemetryAttrExpectations, +): boolean => + Object.entries(attrs).every(([key, matcher]) => { + const actual = row.attrs[key] + if (actual === undefined) return false + return matchesAttr(actual, matcher, row) + }) + +const matchesAttr = ( + actual: string, + matcher: TelemetryAttrMatcher, + row: Row, +): boolean => { + if (matcher instanceof RegExp) return matcher.test(actual) + if (isStructuredAttrMatcher(matcher)) { + switch (matcher._tag) { + case 'Present': + return true + case 'Predicate': + return matcher.predicate(actual, row) + case 'Schema': + return matcher.matches(actual) + } + } + return actual === normalizeAttrPrimitive(matcher) +} + +const contractAttrs = ( + attributes: OtelMetricLabelsContract, + match: A, +): TelemetryAttrExpectations => + Object.fromEntries( + Object.entries(attributes.unsafeEncode(match)).map(([key, value]) => { + if (Array.isArray(value) === true) { + throw new TelemetryExpectError( + `Cannot match array-valued OTEL metric label ${key} against otelite flat rows`, + ) + } + return [key, value] + }), + ) + +const isStructuredAttrMatcher = ( + matcher: TelemetryAttrMatcher, +): matcher is StructuredTelemetryAttrMatcher => + typeof matcher === 'object' && + matcher !== null && + !(matcher instanceof RegExp) && + '_tag' in matcher + +const normalizeAttrPrimitive = (value: TelemetryAttrPrimitive): string => { + if (value === null) return 'null' + return String(value) +} + +const describeMetricSelector = (selector: MetricSelector): string => { + const parts = [ + selector.name === undefined ? undefined : `name=${selector.name}`, + selector.service === undefined ? undefined : `service=${selector.service}`, + selector.type === undefined ? undefined : `type=${selector.type}`, + selector.unit === undefined ? undefined : `unit=${selector.unit}`, + selector.value === undefined ? undefined : `value=${describeMetricValue(selector.value)}`, + selector.attrs === undefined ? undefined : `attrs=${describeAttrs(selector.attrs)}`, + ].filter((part) => part !== undefined) + return parts.length === 0 ? '*' : parts.join(',') +} + +const describeLogSelector = (selector: LogSelector): string => { + const parts = [ + selector.service === undefined ? undefined : `service=${selector.service}`, + selector.scope === undefined ? undefined : `scope=${selector.scope}`, + selector.body === undefined ? undefined : `body=${String(selector.body)}`, + selector.severityText === undefined ? undefined : `severity_text=${selector.severityText}`, + selector.severityNumber === undefined + ? undefined + : `severity_number=${selector.severityNumber}`, + selector.traceId === undefined ? undefined : `trace_id=${selector.traceId}`, + selector.spanId === undefined ? undefined : `span_id=${selector.spanId}`, + selector.attrs === undefined ? undefined : `attrs=${describeAttrs(selector.attrs)}`, + ].filter((part) => part !== undefined) + return parts.length === 0 ? '*' : parts.join(',') +} + +const describeMetricValue = (matcher: MetricValueMatcher): string => { + if (typeof matcher === 'number') return String(matcher) + switch (matcher._tag) { + case 'Present': + return 'present' + case 'Predicate': + return matcher.description + } +} + +const describeAttrs = (attrs: TelemetryAttrExpectations): string => + Object.entries(attrs) + .map(([key, matcher]) => `${key}:${describeMatcher(matcher)}`) + .join(',') + +const describeMatcher = (matcher: TelemetryAttrMatcher): string => { + if (matcher instanceof RegExp) return matcher.toString() + if (isStructuredAttrMatcher(matcher)) { + switch (matcher._tag) { + case 'Present': + return 'present' + case 'Predicate': + return matcher.description + case 'Schema': + return matcher.description + } + } + return normalizeAttrPrimitive(matcher) +} diff --git a/packages/@overeng/utils-dev/src/otelite/test-harness.ts b/packages/@overeng/utils-dev/src/otelite/test-harness.ts index 2f13953c6..1d8926b5b 100644 --- a/packages/@overeng/utils-dev/src/otelite/test-harness.ts +++ b/packages/@overeng/utils-dev/src/otelite/test-harness.ts @@ -5,6 +5,7 @@ import { Effect, Layer, type Scope } from 'effect' import { otlpTracesUrl } from '../node-vitest/Vitest.ts' import type { OteliteCliError, OteliteDecodeError, OteliteSpawnError } from './errors.ts' +import { withOteliteLabelSpan, withOteliteRootSpan } from './otel.ts' import { Otelite } from './Otelite.ts' import type { CaptureHandle, CaptureOptions } from './Otelite.ts' import type { SpanRow } from './schema.ts' @@ -176,10 +177,7 @@ export class OteliteTestHarness extends Effect.Service()( const runInProcess = (effect: Effect.Effect): Effect.Effect => effect.pipe( - Effect.withSpan(rootSpanName, { - root: true, - attributes: { 'span.label': rootSpanLabel }, - }), + withOteliteRootSpan({ name: rootSpanName, label: rootSpanLabel }), Effect.provide(inProcessLayer), ) @@ -224,7 +222,7 @@ export class OteliteTestHarness extends Effect.Service()( withEnv, withEnvTrace, } satisfies OteliteTestHandle - }).pipe(Effect.withSpan('otelite.test-harness.capture')) + }).pipe(withOteliteLabelSpan('otelite.test-harness.capture', 'test-harness.capture')) return { capture } as const }), diff --git a/packages/@overeng/utils-dev/src/otelite/trace-expect.test.ts b/packages/@overeng/utils-dev/src/otelite/trace-expect.test.ts index cdea0e51c..0ad14c1e6 100644 --- a/packages/@overeng/utils-dev/src/otelite/trace-expect.test.ts +++ b/packages/@overeng/utils-dev/src/otelite/trace-expect.test.ts @@ -75,6 +75,33 @@ describe('trace-expect', () => { expect(traces.sameTrace({ service: 'op-proxy' })).toBe('trace-1') }) + it('matches compiled OTEL attribute and span contracts structurally', () => { + const attributes = { + unsafeEncode: (value: { readonly label: string; readonly count: number }) => ({ + 'span.label': value.label, + 'retry.count': value.count, + }), + } + const contract = { + name: 'rpc.op.submit', + attributes, + } + const traces = expectTrace([span({})]) + + expect( + traces.expectAttributes({ + attributes, + match: { label: 'read', count: 2 }, + }), + ).toHaveLength(1) + expect( + traces.expectSpan({ + span: contract, + match: { label: 'read', count: 2 }, + }).span_id, + ).toBe('span-1') + }) + it('throws a runner-agnostic error when expectations fail', () => { const traces = expectTrace([ span({ span_id: 'span-1' }), diff --git a/packages/@overeng/utils-dev/src/otelite/trace-expect.ts b/packages/@overeng/utils-dev/src/otelite/trace-expect.ts index ae4e1b59c..f881feded 100644 --- a/packages/@overeng/utils-dev/src/otelite/trace-expect.ts +++ b/packages/@overeng/utils-dev/src/otelite/trace-expect.ts @@ -35,6 +35,31 @@ export interface SpanSelector { readonly requireLabel?: boolean } +/** Structural view of a compiled schema-backed OTEL attribute contract. */ +export interface OtelAttrsContract { + readonly unsafeEncode: (value: A) => Readonly> +} + +/** Structural view of a named schema-backed OTEL span contract. */ +export interface OtelSpanContract { + readonly name: string + readonly attributes: OtelAttrsContract +} + +/** Selector that derives otelite row matchers from a compiled attribute contract. */ +export interface ContractAttributesSelector { + readonly attributes: OtelAttrsContract + readonly match: A + readonly selector?: Omit +} + +/** Selector that derives name, label policy, and attributes from a compiled span contract. */ +export interface ContractSpanSelector { + readonly span: OtelSpanContract + readonly match: A + readonly selector?: Omit +} + export class TraceExpectError extends Error { readonly _tag = 'TraceExpectError' } @@ -150,6 +175,22 @@ export class TraceExpect { return matches } + expectAttributes(selector: ContractAttributesSelector): readonly SpanRow[] { + return this.expectSome({ + ...selector.selector, + attrs: contractAttrs(selector.attributes, selector.match), + }) + } + + expectSpan(selector: ContractSpanSelector): SpanRow { + return this.expectOne({ + ...selector.selector, + name: selector.span.name, + attrs: contractAttrs(selector.span.attributes, selector.match), + requireLabel: true, + }) + } + expectSameTrace(selectors: readonly SpanSelector[]): string { const spans = selectors.map((selector) => this.expectOne(selector)) const traceIds = new Set(spans.map((span) => span.trace_id).filter((id) => id !== null)) @@ -198,6 +239,18 @@ const matchesAttr = (actual: string, matcher: AttrMatcher, span: SpanRow): boole return actual === normalizeAttrPrimitive(matcher) } +const contractAttrs = (attributes: OtelAttrsContract, match: A): AttrExpectations => + Object.fromEntries( + Object.entries(attributes.unsafeEncode(match)).map(([key, value]) => { + if (Array.isArray(value) === true) { + throw new TraceExpectError( + `Cannot match array-valued OTEL attribute ${key} against otelite flat rows`, + ) + } + return [key, value] + }), + ) + const isStructuredAttrMatcher = (matcher: AttrMatcher): matcher is StructuredAttrMatcher => typeof matcher === 'object' && matcher !== null && diff --git a/packages/@overeng/utils/package.json b/packages/@overeng/utils/package.json index 3d9be8a07..a4e0830db 100644 --- a/packages/@overeng/utils/package.json +++ b/packages/@overeng/utils/package.json @@ -15,6 +15,7 @@ "./node/cli-help-rewrite": "./src/node/cli-help-rewrite.ts", "./node/cli-version": "./src/node/cli-version.ts", "./node/otel": "./src/node/otel.ts", + "./node/otel-attrs": "./src/node/otel-attrs.ts", "./node/playwright": "./src/node/playwright/mod.ts", "./node/playwright/config": "./src/node/playwright/config/mod.ts", "./node/storybook": "./src/node/storybook/mod.ts", @@ -28,6 +29,7 @@ "./node/cli-help-rewrite": "./dist/node/cli-help-rewrite.js", "./node/cli-version": "./dist/node/cli-version.js", "./node/otel": "./dist/node/otel.js", + "./node/otel-attrs": "./dist/node/otel-attrs.js", "./node/playwright": "./dist/node/playwright/mod.js", "./node/playwright/config": "./dist/node/playwright/config/mod.js", "./node/storybook": "./dist/node/storybook/mod.js", @@ -47,6 +49,7 @@ "@effect/vitest": "0.29.0", "@noble/hashes": "1.7.1", "@opentelemetry/api": "1.9.0", + "@overeng/otel-contract": "workspace:^", "effect": "3.21.2", "effect-distributed-lock": "0.0.11", "ioredis": "5.6.1", @@ -99,6 +102,7 @@ "source": "package.json.genie.ts", "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev" ] diff --git a/packages/@overeng/utils/package.json.genie.ts b/packages/@overeng/utils/package.json.genie.ts index d159b1b84..8c6b8a017 100644 --- a/packages/@overeng/utils/package.json.genie.ts +++ b/packages/@overeng/utils/package.json.genie.ts @@ -7,6 +7,7 @@ import { privatePackageDefaults, type PackageJsonData, } from '../../../genie/internal.ts' +import otelContractPkg from '../otel-contract/package.json.genie.ts' import utilsDevPkg from '../utils-dev/package.json.genie.ts' /** Packages exposed as peer deps (consumers provide) + included in devDeps (for local dev/test) */ @@ -25,6 +26,7 @@ const peerDepNames = [ const runtimeDeps = catalog.compose({ workspace: workspaceMember({ memberPath: 'packages/@overeng/utils' }), dependencies: { + workspace: [otelContractPkg], external: catalog.pick( '@noble/hashes', '@opentelemetry/api', @@ -64,6 +66,7 @@ export default packageJson( './node/cli-help-rewrite': './src/node/cli-help-rewrite.ts', './node/cli-version': './src/node/cli-version.ts', './node/otel': './src/node/otel.ts', + './node/otel-attrs': './src/node/otel-attrs.ts', './node/playwright': './src/node/playwright/mod.ts', './node/playwright/config': './src/node/playwright/config/mod.ts', './node/storybook': './src/node/storybook/mod.ts', @@ -86,6 +89,7 @@ export default packageJson( './node/cli-help-rewrite': './dist/node/cli-help-rewrite.js', './node/cli-version': './dist/node/cli-version.js', './node/otel': './dist/node/otel.js', + './node/otel-attrs': './dist/node/otel-attrs.js', './node/playwright': './dist/node/playwright/mod.js', './node/playwright/config': './dist/node/playwright/config/mod.js', './node/storybook': './dist/node/storybook/mod.js', diff --git a/packages/@overeng/utils/src/browser/BroadcastLogger.ts b/packages/@overeng/utils/src/browser/BroadcastLogger.ts index 6774820b5..a85ca04f8 100644 --- a/packages/@overeng/utils/src/browser/BroadcastLogger.ts +++ b/packages/@overeng/utils/src/browser/BroadcastLogger.ts @@ -31,7 +31,7 @@ * * yield* Effect.gen(function* () { * yield* Effect.log('Syncing records') - * }).pipe(Effect.withSpan('sync-operation')) + * }) * * yield* Effect.logError('Connection failed', { retries: 3 }) * }).pipe( @@ -82,9 +82,37 @@ import { Stream, } from 'effect' +import { + OtelAttr, + OtelOperation, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + /** Channel name for broadcasting logs */ export const BROADCAST_CHANNEL_NAME = 'effect-debug-logs' +const BroadcastLoggerLogStreamSetupOperation = OtelOperation.define({ + name: 'BroadcastLogger.logStream.setup', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + }), + label: ({ label }) => label, +}) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + const sanitizeForBroadcast = (value: unknown): unknown => { if (value === null || value === undefined) return value @@ -234,7 +262,7 @@ export const logStream: Stream.Stream = channel.close() }), ) - }).pipe(Effect.withSpan('BroadcastLogger.logStream.setup')), + }).pipe(trustedWith(BroadcastLoggerLogStreamSetupOperation, { label: 'setup' })), ) /** Options for creating a log bridge layer. */ diff --git a/packages/@overeng/utils/src/node/ActiveHandlesDebugger.ts b/packages/@overeng/utils/src/node/ActiveHandlesDebugger.ts index 6ac14e1aa..553b3a1b7 100644 --- a/packages/@overeng/utils/src/node/ActiveHandlesDebugger.ts +++ b/packages/@overeng/utils/src/node/ActiveHandlesDebugger.ts @@ -4,7 +4,14 @@ * Node.js processes stay alive while there are active handles (timers, sockets, etc.) * or pending requests. This module provides Effect-native APIs to inspect these. */ -import { type Duration, Effect, Runtime, Schedule, Stream } from 'effect' +import { type Duration, Effect, Runtime, Schedule, Schema, Stream } from 'effect' + +import { + OtelAttr, + OtelOperation, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' /** Information about a single active handle */ export interface HandleInfo { @@ -20,6 +27,27 @@ export interface ActiveHandlesInfo { readonly totalRequests: number } +const ActiveHandlesLogOperation = OtelOperation.define({ + name: 'ActiveHandlesDebugger.logActiveHandles', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + }), + label: ({ label }) => label, +}) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + /** Categorizes a handle by its constructor name and extracts useful details */ const categorizeHandle = (handle: unknown): HandleInfo => { const type = handle?.constructor?.name ?? 'Unknown' @@ -103,7 +131,7 @@ export const logActiveHandles = Effect.gen(function* () { requests: info.requests, }) return info -}).pipe(Effect.withSpan('ActiveHandlesDebugger.logActiveHandles')) +}).pipe(trustedWith(ActiveHandlesLogOperation, { label: 'dump' })) /** * Monitors active handles periodically and logs when the count changes. diff --git a/packages/@overeng/utils/src/node/cmd.ts b/packages/@overeng/utils/src/node/cmd.ts index df1e78476..e7fbaa587 100644 --- a/packages/@overeng/utils/src/node/cmd.ts +++ b/packages/@overeng/utils/src/node/cmd.ts @@ -22,6 +22,13 @@ import { Stream, } from 'effect' +import { + OtelAttr, + OtelOperation, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + import { isNotUndefined } from '../isomorphic/mod.ts' import { applyLoggingToCommand } from './cmd-log.ts' import * as FileLogger from './FileLogger.ts' @@ -30,6 +37,56 @@ import { CurrentWorkingDirectory } from './workspace.ts' // Branded zero value so we can compare exit codes without touching internals. const SUCCESS_EXIT_CODE: CommandExecutor.ExitCode = 0 as CommandExecutor.ExitCode +const CmdRunOperation = OtelOperation.define({ + name: 'cmd.run', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + cwd: Schema.String.pipe(OtelAttr.key({ key: 'cmd.cwd' })), + command: Schema.String.pipe(OtelAttr.key({ key: 'cmd.command' })), + args: Schema.Array(Schema.String).pipe(OtelAttr.key({ key: 'cmd.args', encode: 'json' })), + logDir: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'cmd.log_dir' }))), + shell: Schema.Boolean.pipe(OtelAttr.key({ key: 'cmd.shell' })), + }), + label: ({ label }) => label, +}) + +const CmdCollectOperation = OtelOperation.define({ + name: 'cmd.collect', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + cwd: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'cmd.cwd' }))), + shell: Schema.Boolean.pipe(OtelAttr.key({ key: 'cmd.shell' })), + }), + label: ({ label }) => label, +}) + +const CmdLoggingOperation = OtelOperation.define({ + name: 'cmd.run-with-logging', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + cwd: Schema.String.pipe(OtelAttr.key({ key: 'cmd.cwd' })), + logPath: Schema.String.pipe(OtelAttr.key({ key: 'cmd.log_path' })), + shell: Schema.Boolean.pipe(OtelAttr.key({ key: 'cmd.shell' })), + }), + label: ({ label }) => label, +}) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + +const annotateCmdRun = (value: Parameters[0]) => + CmdRunOperation.annotate(value).pipe(Effect.orDie) + /** * Run a command to completion and return its exit code. * @@ -111,12 +168,13 @@ export const cmd: ( const subshellStr = useShell === true ? ' (in subshell)' : '' yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`) - yield* Effect.annotateCurrentSpan({ - 'span.label': commandDebugStr, + yield* annotateCmdRun({ + label: commandDebugStr, cwd, command, args, - logDir: options?.logDir, + ...(options?.logDir === undefined ? {} : { logDir: options.logDir }), + shell: useShell, }) const baseArgs = { @@ -211,11 +269,12 @@ export const cmdStart: ( const subshellStr = useShell === true ? ' (in subshell)' : '' yield* Effect.logDebug(`Starting '${commandDebugStr}' in '${cwd}'${subshellStr}`) - yield* Effect.annotateCurrentSpan({ - 'span.label': commandDebugStr, + yield* annotateCmdRun({ + label: commandDebugStr, cwd, command, args, + shell: useShell, }) return yield* buildCommand({ input: normalizedInput, useShell }).pipe( @@ -267,10 +326,12 @@ export const cmdText: ( const subshellStr = options?.runInShell === true ? ' (in subshell)' : '' yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`) - yield* Effect.annotateCurrentSpan({ - 'span.label': commandDebugStr, + yield* annotateCmdRun({ + label: commandDebugStr, command, cwd, + args, + shell: options?.runInShell === true, }) return yield* Command.make(command, ...args).pipe( @@ -371,7 +432,16 @@ export const cmdCollect = (opts: { ), ), ) - }).pipe(Effect.withSpan('cmdCollect')) + }).pipe( + trustedWith(CmdCollectOperation, { + label: + Array.isArray(opts.commandInput) === true + ? opts.commandInput.filter(isNotUndefined).join(' ') + : opts.commandInput, + ...(opts.workingDirectory === undefined ? {} : { cwd: opts.workingDirectory }), + shell: opts.shell === true, + }), + ) /** Internal error for process signal operations */ class ProcessSignalError extends Schema.TaggedError()('ProcessSignalError', { @@ -539,7 +609,14 @@ const runWithLogging = ({ return exitCode }), - ).pipe(Effect.withSpan('cmd.runWithLogging')) + ).pipe( + trustedWith(CmdLoggingOperation, { + label: threadName, + cwd, + logPath, + shell: useShell, + }), + ) /** Default grace period before escalating from SIGTERM to SIGKILL */ const DEFAULT_KILL_TIMEOUT: Duration.DurationInput = '5 seconds' diff --git a/packages/@overeng/utils/src/node/file-system-backing.ts b/packages/@overeng/utils/src/node/file-system-backing.ts index 0e46764f8..453a8f2b1 100644 --- a/packages/@overeng/utils/src/node/file-system-backing.ts +++ b/packages/@overeng/utils/src/node/file-system-backing.ts @@ -2,6 +2,8 @@ import { FileSystem, Path } from '@effect/platform' import { Cause, Data, Duration, Effect, Layer, Option, Schema, Stream } from 'effect' import { DistributedSemaphoreBacking, SemaphoreBackingError } from 'effect-distributed-lock' +import { OtelAttr, OtelOperation } from '@overeng/otel-contract' + /** Information about a holder's lock state */ export interface HolderInfo { readonly holderId: string @@ -22,6 +24,25 @@ const HolderLockSchema = Schema.Struct({ type HolderLockContent = typeof HolderLockSchema.Type +const SemaphoreKeyOperation = OtelOperation.define({ + name: 'FileSystemBacking.semaphore.key', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + key: Schema.String.pipe(OtelAttr.key({ key: 'semaphore.key' })), + }), + label: ({ label }) => label, +}) + +const SemaphoreForceRevokeOperation = OtelOperation.define({ + name: 'FileSystemBacking.semaphore.forceRevoke', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + key: Schema.String.pipe(OtelAttr.key({ key: 'semaphore.key' })), + targetHolderId: Schema.String.pipe(OtelAttr.key({ key: 'semaphore.target_holder_id' })), + }), + label: ({ label }) => label, +}) + /** * Options for the file-system based semaphore backing. */ @@ -393,10 +414,11 @@ export const forceRevoke = Effect.fn('FileSystemBacking.forceRevoke')(function* key: string targetHolderId: string }) { - yield* Effect.annotateCurrentSpan({ + yield* SemaphoreForceRevokeOperation.annotate({ + label: opts.key, key: opts.key, targetHolderId: opts.targetHolderId, - }) + }).pipe(Effect.orDie) const { options, key, targetHolderId } = opts const { lockDir } = options @@ -428,7 +450,7 @@ export const listHolders = Effect.fn('FileSystemBacking.listHolders')(function* options: FileSystemBackingOptions key: string }) { - yield* Effect.annotateCurrentSpan({ key: opts.key }) + yield* SemaphoreKeyOperation.annotate({ label: opts.key, key: opts.key }).pipe(Effect.orDie) const { options, key } = opts const { lockDir } = options @@ -490,7 +512,7 @@ export const forceRevokeAll = Effect.fn('FileSystemBacking.forceRevokeAll')(func options: FileSystemBackingOptions key: string }) { - yield* Effect.annotateCurrentSpan({ key: opts.key }) + yield* SemaphoreKeyOperation.annotate({ label: opts.key, key: opts.key }).pipe(Effect.orDie) const { options, key } = opts const holders = yield* listHolders({ options, key }) diff --git a/packages/@overeng/utils/src/node/mod.ts b/packages/@overeng/utils/src/node/mod.ts index 36be25e72..359c0d5a1 100644 --- a/packages/@overeng/utils/src/node/mod.ts +++ b/packages/@overeng/utils/src/node/mod.ts @@ -22,3 +22,6 @@ export * from './cli-version.ts' /** Rewrite `help ` → ` --help` for @effect/cli compatibility */ export * from './cli-help-rewrite.ts' + +/** Schema-first OTEL attribute and span contracts */ +export * from './otel-attrs.ts' diff --git a/packages/@overeng/utils/src/node/otel-attrs.ts b/packages/@overeng/utils/src/node/otel-attrs.ts new file mode 100644 index 000000000..6f5f45266 --- /dev/null +++ b/packages/@overeng/utils/src/node/otel-attrs.ts @@ -0,0 +1 @@ +export * from '@overeng/otel-contract' diff --git a/packages/@overeng/utils/src/node/otel-attrs.unit.test.ts b/packages/@overeng/utils/src/node/otel-attrs.unit.test.ts new file mode 100644 index 000000000..c0f393288 --- /dev/null +++ b/packages/@overeng/utils/src/node/otel-attrs.unit.test.ts @@ -0,0 +1,377 @@ +import { DateTime, Duration, Effect, Option, Redacted, Schema } from 'effect' +import { describe, expect, it } from 'vitest' + +import { expectTrace } from '@overeng/utils-dev/otelite' + +import { + OtelAttr, + OtelAttrEncodeError, + OtelAttrPlanError, + OtelAttrs, + OtelSpan, +} from './otel-attrs.ts' + +describe('OtelAttrs', () => { + it('derives primitive, literal, uuid, option, date, duration, and explicit array attributes', async () => { + const Attrs = Schema.Struct({ + label: Schema.NonEmptyTrimmedString.pipe(OtelAttr.spanLabel()), + requestId: Schema.UUID.pipe(OtelAttr.key({ key: 'request.id' })), + outcome: Schema.Literal('approved', 'denied', 'timeout').pipe( + OtelAttr.key({ key: 'op.outcome' }), + ), + count: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'op.count' })), + cacheHit: Schema.Boolean.pipe(OtelAttr.key({ key: 'op.cache_hit' })), + maybeShard: Schema.OptionFromNullOr(Schema.String).pipe(OtelAttr.key({ key: 'op.shard' })), + at: Schema.DateTimeUtc.pipe(OtelAttr.key({ key: 'op.at' })), + latency: Schema.DurationFromMillis.pipe(OtelAttr.key({ key: 'op.latency_ms' })), + tags: Schema.Array(Schema.String).pipe(OtelAttr.key({ key: 'op.tags', encode: 'json' })), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + const at = DateTime.unsafeMake('2026-06-11T10:00:00.000Z') + + await expect( + Effect.runPromise( + attrs.encode({ + label: 'submit', + requestId: '123e4567-e89b-12d3-a456-426614174000', + outcome: 'approved', + count: 2, + cacheHit: false, + maybeShard: Option.some('dev3'), + at, + latency: Duration.millis(42), + tags: ['safe', 'bounded'], + }), + ), + ).resolves.toEqual({ + 'span.label': 'submit', + 'request.id': '123e4567-e89b-12d3-a456-426614174000', + 'op.outcome': 'approved', + 'op.count': 2, + 'op.cache_hit': false, + 'op.shard': 'dev3', + 'op.at': '2026-06-11T10:00:00.000Z', + 'op.latency_ms': 42, + 'op.tags': '["safe","bounded"]', + }) + + await expect( + Effect.runPromise( + attrs.encode({ + label: 'submit', + requestId: '123e4567-e89b-12d3-a456-426614174000', + outcome: 'approved', + count: 2, + cacheHit: false, + maybeShard: Option.none(), + at, + latency: Duration.millis(42), + tags: [], + }), + ), + ).resolves.not.toHaveProperty('op.shard') + }) + + it('rejects unsafe schemas unless policy is explicit', async () => { + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + nested: Schema.Struct({ id: Schema.String }).pipe(OtelAttr.key({ key: 'nested' })), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe(OtelAttr.key({ key: 'secret' })), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + tags: Schema.Array(Schema.String).pipe(OtelAttr.key({ key: 'tags' })), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + }) + + it('allows explicit redacted and json policies', async () => { + const Attrs = Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'redacted' }), + ), + nested: Schema.Struct({ id: Schema.String }).pipe( + OtelAttr.key({ key: 'nested', encode: 'json' }), + ), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + + await expect( + Effect.runPromise( + attrs.encode({ + secret: Redacted.make('do-not-leak'), + nested: { id: 'n1' }, + }), + ), + ).resolves.toEqual({ + secret: '', + nested: '{"id":"n1"}', + }) + }) + + it('only allows redacted-safe policies for redacted values', async () => { + await expect( + Effect.runPromise( + Effect.either( + OtelAttrs.define( + Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'json' }), + ), + }), + ), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrPlanError), + }) + + const attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'drop' }), + ), + }), + ), + ) + + await expect( + Effect.runPromise(attrs.encode({ secret: Redacted.make('do-not-leak') })), + ).resolves.toEqual({}) + }) + + it('surfaces encoding errors on the error channel', async () => { + const Attrs = Schema.Struct({ + count: Schema.Number.pipe(OtelAttr.key({ key: 'count' })), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + + await expect( + Effect.runPromise(Effect.either(attrs.encode({ count: Number.NaN }))), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrEncodeError), + }) + }) + + it('validates explicit policy inputs before encoding', async () => { + const Attrs = Schema.Struct({ + asJson: Schema.Struct({ id: Schema.String }).pipe( + OtelAttr.key({ key: 'json', encode: 'json' }), + ), + asString: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'string', encode: 'string' })), + asNumber: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'number', encode: 'number' })), + asBoolean: Schema.Boolean.pipe(OtelAttr.key({ key: 'boolean', encode: 'boolean' })), + secret: Schema.Redacted(Schema.String).pipe( + OtelAttr.key({ key: 'secret', encode: 'redacted' }), + ), + }) + const attrs = await Effect.runPromise(OtelAttrs.define(Attrs)) + + const invalidInputs = [ + { + asJson: { id: 1 }, + asString: 1, + asNumber: 1, + asBoolean: true, + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: -1, + asNumber: 1, + asBoolean: true, + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: 1, + asNumber: Number.NaN, + asBoolean: true, + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: 1, + asNumber: 1, + asBoolean: 'true', + secret: Redacted.make('ok'), + }, + { + asJson: { id: 'ok' }, + asString: 1, + asNumber: 1, + asBoolean: true, + secret: Redacted.make(1), + }, + ] + const results = await Promise.all( + invalidInputs.map((invalid) => + Effect.runPromise(Effect.either(attrs.encode(invalid as never))), + ), + ) + + for (const result of results) { + expect(result).toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrEncodeError), + }) + } + }) + + it('feeds compiled attributes into otelite trace expectations', async () => { + const attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + label: Schema.String.pipe(OtelAttr.spanLabel()), + count: Schema.NonNegativeInt.pipe(OtelAttr.key({ key: 'retry.count' })), + }), + ), + ) + const span = OtelSpan.define({ name: 'rpc.op.submit', attributes: attrs }) + const trace = expectTrace([ + { + schema: 'otelite.span/v1', + service: 'op-proxy', + name: 'rpc.op.submit', + trace_id: 'trace-1', + span_id: 'span-1', + parent_span_id: null, + start_unix_nano: '1', + end_unix_nano: '2', + duration_ms: 1, + status_code: 0, + attrs: { + 'span.label': 'read', + 'retry.count': '2', + }, + }, + ]) + + expect( + trace.expectAttributes({ + attributes: attrs, + match: { label: 'read', count: 2 }, + }), + ).toHaveLength(1) + expect( + trace.expectSpan({ + span, + match: { label: 'read', count: 2 }, + }).span_id, + ).toBe('span-1') + }) +}) + +describe('OtelSpan', () => { + it('wraps effects with schema-backed attributes', async () => { + const Attrs = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + label: Schema.String.pipe(OtelAttr.spanLabel()), + }), + ), + ) + const span = OtelSpan.define({ name: 'test.span', attributes: Attrs }) + + await expect( + Effect.runPromise( + OtelSpan.with({ + span, + attributes: { label: 'contract' }, + effect: Effect.succeed('ok'), + }), + ), + ).resolves.toBe('ok') + + await expect( + Effect.runPromise( + Effect.succeed('ok').pipe(OtelSpan.with({ span, attributes: { label: 'pipe' } })), + ), + ).resolves.toBe('ok') + }) + + it('requires span.label at definition and runtime', async () => { + const WithoutLabel = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + value: Schema.String.pipe(OtelAttr.key({ key: 'value' })), + }), + ), + ) + expect(() => OtelSpan.define({ name: 'test.no-label', attributes: WithoutLabel })).toThrow( + OtelAttrPlanError, + ) + + const AccidentalLabel = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + value: Schema.String.pipe(OtelAttr.key({ key: 'span.label' })), + }), + ), + ) + expect(() => + OtelSpan.define({ name: 'test.accidental-label', attributes: AccidentalLabel }), + ).toThrow(OtelAttrPlanError) + + const WithOptionalLabel = await Effect.runPromise( + OtelAttrs.define( + Schema.Struct({ + label: Schema.optional(Schema.String.pipe(OtelAttr.spanLabel())), + }), + ), + ) + const span = OtelSpan.define({ name: 'test.optional-label', attributes: WithOptionalLabel }) + + await expect( + Effect.runPromise( + Effect.either( + OtelSpan.with({ + span, + attributes: {}, + effect: Effect.succeed('ok'), + }), + ), + ), + ).resolves.toMatchObject({ + _tag: 'Left', + left: expect.any(OtelAttrEncodeError), + }) + }) +}) diff --git a/packages/@overeng/utils/src/node/otel.ts b/packages/@overeng/utils/src/node/otel.ts index aa6c045af..440b4ab4e 100644 --- a/packages/@overeng/utils/src/node/otel.ts +++ b/packages/@overeng/utils/src/node/otel.ts @@ -16,6 +16,8 @@ import * as Otlp from '@effect/opentelemetry/Otlp' import { FetchHttpClient } from '@effect/platform' import { Effect, Layer, Tracer } from 'effect' +export * from './otel-attrs.ts' + /** * Parses a W3C Trace Context TRACEPARENT header/env var. * @@ -142,7 +144,7 @@ export const makeOtelCliLayer = (config: OtelCliLayerConfig): Layer.Layer // Propagate parent trace context from TRACEPARENT without creating a bridge span. // Layer.parentSpan sets the external parent so child spans (e.g. command-level - // Effect.withSpan) appear under the dt task span in the trace. + // schema-first operation spans) appear under the dt task span in the trace. const parentLive = parentSpan !== undefined ? Layer.parentSpan(parentSpan) : Layer.empty const baseUrl = endpoint.endsWith('/') === true ? endpoint.slice(0, -1) : endpoint diff --git a/packages/@overeng/utils/src/node/playwright/context.ts b/packages/@overeng/utils/src/node/playwright/context.ts index 536989c3b..fce6b16cc 100644 --- a/packages/@overeng/utils/src/node/playwright/context.ts +++ b/packages/@overeng/utils/src/node/playwright/context.ts @@ -5,11 +5,31 @@ */ import type { Cookie } from '@playwright/test' -import { Effect } from 'effect' +import { Effect, Schema } from 'effect' + +import { OtelAttr, OtelAttrs, OtelSpan } from '@overeng/otel-contract' import { type PwOpError, tryPw } from './op.ts' import { PwBrowserContext } from './tags.ts' +const PwContextAttrs = OtelAttrs.defineSync( + Schema.Struct({ + cookieCount: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.cookie.count' }))), + cookiesUrl: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.cookies.url' }))), + storageStatePath: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: 'pw.storageState.path' })), + ), + }), +) + +const annotateContext = ( + value: Partial<{ + cookieCount: number + cookiesUrl: string + storageStatePath: string + }>, +) => OtelSpan.annotate({ attributes: PwContextAttrs, value }).pipe(Effect.orDie) + /** * Reads cookies from the current browser context. * @@ -28,9 +48,9 @@ export const cookies: (args: { effect: () => (url !== undefined ? context.cookies(url) : context.cookies()), }).pipe( Effect.tap((cs) => - Effect.annotateCurrentSpan({ - 'pw.cookie.count': cs.length, - 'pw.cookies.url': + annotateContext({ + cookieCount: cs.length, + cookiesUrl: url !== undefined ? (Array.isArray(url) === true ? url.join(' | ') : url) : '', }), ), @@ -53,7 +73,7 @@ export const storageState: (args: { yield* tryPw({ op: 'pw.context.storageState', effect: () => context.storageState({ path }).then(() => undefined), - }).pipe(Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.storageState.path': path }))) + }).pipe(Effect.tap(() => annotateContext({ storageStatePath: path }))) }), ) @@ -70,9 +90,7 @@ export const addCookies: (args: { yield* tryPw({ op: 'pw.context.addCookies', effect: () => context.addCookies(cookiesToAdd), - }).pipe( - Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.cookie.count': cookiesToAdd.length })), - ) + }).pipe(Effect.tap(() => annotateContext({ cookieCount: cookiesToAdd.length }))) }), ) diff --git a/packages/@overeng/utils/src/node/playwright/locator.ts b/packages/@overeng/utils/src/node/playwright/locator.ts index 03fdc2792..44353f006 100644 --- a/packages/@overeng/utils/src/node/playwright/locator.ts +++ b/packages/@overeng/utils/src/node/playwright/locator.ts @@ -5,11 +5,51 @@ */ import type { Locator, Page } from '@playwright/test' -import { Effect } from 'effect' +import { Effect, Schema } from 'effect' + +import { OtelAttr, OtelAttrs, OtelSpan } from '@overeng/otel-contract' import { type PwOpError, tryPw } from './op.ts' import { PwPage } from './tags.ts' +const PwLocatorAttrs = OtelAttrs.defineSync( + Schema.Struct({ + timeoutMs: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.timeout.ms' }))), + valueLen: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.value.len' }))), + textLen: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.text.len' }))), + delayMs: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.delay.ms' }))), + jitterMs: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.jitter.ms' }))), + key: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.key' }))), + selector: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.selector' }))), + role: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.role' }))), + name: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.name' }))), + testId: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.testId' }))), + text: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.text' }))), + label: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.label' }))), + placeholder: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.placeholder' }))), + }), +) + +const annotateLocator = ( + value: Partial<{ + timeoutMs: number + valueLen: number + textLen: number + delayMs: number + jitterMs: number + key: string + selector: string + role: string + name: string + testId: string + text: string + label: string + placeholder: string + }>, +) => OtelSpan.annotate({ attributes: PwLocatorAttrs, value }).pipe(Effect.orDie) + +const textLabel = (text: string | RegExp) => (typeof text === 'string' ? text : text.source) + /** Waits for the locator to become visible. */ export const waitForVisible: (args: { /** Locator to wait on. */ @@ -25,7 +65,7 @@ export const waitForVisible: (args: { state: 'visible', ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), }), - }).pipe(Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.timeout.ms': timeoutMs ?? 0 }))), + }).pipe(Effect.tap(() => annotateLocator({ timeoutMs: timeoutMs ?? 0 }))), ) /** Clicks the locator. Prefer a11y-first interactions when available. */ @@ -44,7 +84,7 @@ export const fill: (args: { value: string }) => Effect.Effect = Effect.fn('pw.locator.fill')(({ locator, value }) => tryPw({ op: 'pw.locator.fill', effect: () => locator.fill(value) }).pipe( - Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.value.len': value.length })), + Effect.tap(() => annotateLocator({ valueLen: value.length })), Effect.asVoid, ), ) @@ -63,12 +103,7 @@ export const type: (args: { effect: () => locator.pressSequentially(text, delayMs !== undefined ? { delay: delayMs } : undefined), }).pipe( - Effect.tap(() => - Effect.annotateCurrentSpan({ - 'pw.text.len': text.length, - 'pw.delay.ms': delayMs ?? 0, - }), - ), + Effect.tap(() => annotateLocator({ textLen: text.length, delayMs: delayMs ?? 0 })), Effect.asVoid, ), ) @@ -89,7 +124,7 @@ export const press: (args: { key: string }) => Effect.Effect = Effect.fn('pw.locator.press')(({ locator, key }) => tryPw({ op: 'pw.locator.press', effect: () => locator.press(key) }).pipe( - Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.key': key })), + Effect.tap(() => annotateLocator({ key })), Effect.asVoid, ), ) @@ -138,17 +173,13 @@ export const typeHuman: (args: { const delay = Math.floor(Math.random() * (delayMsMax - delayMsMin + 1)) + delayMsMin const jitter = Math.floor(Math.random() * (jitterMsMax - jitterMsMin + 1)) + jitterMsMin - yield* Effect.annotateCurrentSpan({ - 'pw.text.len': text.length, - 'pw.delay.ms': delay, - 'pw.jitter.ms': jitter, - }) + yield* annotateLocator({ textLen: text.length, delayMs: delay, jitterMs: jitter }) yield* click({ locator }) yield* tryPw({ op: 'pw.page.waitForTimeout', effect: () => locator.page().waitForTimeout(jitter), - }).pipe(Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.jitter.ms': jitter }))) + }).pipe(Effect.tap(() => annotateLocator({ jitterMs: jitter }))) yield* fill({ locator, value: '' }) yield* type({ locator, text, delayMs: delay }) }), @@ -193,7 +224,7 @@ export const locator: ( ) => Effect.Effect = Effect.fn('pw.locator')((selector) => Effect.gen(function* () { const page = yield* PwPage - yield* Effect.annotateCurrentSpan({ 'pw.selector': selector }) + yield* annotateLocator({ selector }) return page.locator(selector) }), ) @@ -207,10 +238,7 @@ export const getByRole: (opts: { }) => Effect.Effect = Effect.fn('pw.getByRole')((opts) => Effect.gen(function* () { const page = yield* PwPage - yield* Effect.annotateCurrentSpan({ - 'pw.role': String(opts.role), - 'pw.name': opts.options?.name ?? '', - }) + yield* annotateLocator({ role: String(opts.role), name: textLabel(opts.options?.name ?? '') }) return page.getByRole(opts.role, opts.options) }), ) @@ -222,7 +250,7 @@ export const getByTestId: ( ) => Effect.Effect = Effect.fn('pw.getByTestId')((testId) => Effect.gen(function* () { const page = yield* PwPage - yield* Effect.annotateCurrentSpan({ 'pw.testId': testId }) + yield* annotateLocator({ testId }) return page.getByTestId(testId) }), ) @@ -236,9 +264,7 @@ export const getByText: (opts: { }) => Effect.Effect = Effect.fn('pw.getByText')((opts) => Effect.gen(function* () { const page = yield* PwPage - yield* Effect.annotateCurrentSpan({ - 'pw.text': typeof opts.text === 'string' ? opts.text : opts.text.source, - }) + yield* annotateLocator({ text: textLabel(opts.text) }) return page.getByText(opts.text, opts.options) }), ) @@ -252,9 +278,7 @@ export const getByLabel: (opts: { }) => Effect.Effect = Effect.fn('pw.getByLabel')((opts) => Effect.gen(function* () { const page = yield* PwPage - yield* Effect.annotateCurrentSpan({ - 'pw.label': typeof opts.text === 'string' ? opts.text : opts.text.source, - }) + yield* annotateLocator({ label: textLabel(opts.text) }) return page.getByLabel(opts.text, opts.options) }), ) @@ -268,9 +292,7 @@ export const getByPlaceholder: (opts: { }) => Effect.Effect = Effect.fn('pw.getByPlaceholder')((opts) => Effect.gen(function* () { const page = yield* PwPage - yield* Effect.annotateCurrentSpan({ - 'pw.placeholder': typeof opts.text === 'string' ? opts.text : opts.text.source, - }) + yield* annotateLocator({ placeholder: textLabel(opts.text) }) return page.getByPlaceholder(opts.text, opts.options) }), ) diff --git a/packages/@overeng/utils/src/node/playwright/op.ts b/packages/@overeng/utils/src/node/playwright/op.ts index 8e641d86e..fe9af8e53 100644 --- a/packages/@overeng/utils/src/node/playwright/op.ts +++ b/packages/@overeng/utils/src/node/playwright/op.ts @@ -6,6 +6,15 @@ import { Effect, Schema } from 'effect' +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + /** * Canonical error type for Playwright promise bridging. * @@ -19,6 +28,41 @@ export class PwOpError extends Schema.TaggedError()('PwOpError', { cause: Schema.Defect, }) {} +const PwOpOperation = (op: string) => + OtelOperation.define({ + name: op, + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + op: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'pw.op' })), + }), + label: ({ label }) => label, + }) + +const PwTryAttrs = OtelAttrs.defineSync( + Schema.Struct({ + op: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'pw.try.op' })), + }), +) + +const PwExpectAttrs = OtelAttrs.defineSync( + Schema.Struct({ + assertion: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'pw.expect.assertion' })), + }), +) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + /** * Internal helper to wrap Playwright promises into Effects. * @@ -37,7 +81,7 @@ export const tryPw = ({ Effect.tryPromise({ try: effect, catch: (cause) => new PwOpError({ op, cause }), - }).pipe(Effect.withSpan(op, { attributes: { 'pw.op': op } })) + }).pipe(trustedWith(PwOpOperation(op), { label: op, op })) /** * Generic fallback for wrapping any Playwright promise into an Effect. @@ -61,7 +105,9 @@ export const try_: (opts: { effect: () => PromiseLike }) => Effect.Effect = ({ op, effect }) => tryPw({ op: `pw.try.${op}`, effect }).pipe( - Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.try.op': op })), + Effect.tap(() => + OtelSpan.annotate({ attributes: PwTryAttrs, value: { op } }).pipe(Effect.orDie), + ), ) /** @@ -80,5 +126,7 @@ export const expect_: (opts: { expectPromise: PromiseLike }) => Effect.Effect = ({ assertion, expectPromise }) => tryPw({ op: `pw.expect.${assertion}`, effect: () => expectPromise }).pipe( - Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.expect.assertion': assertion })), + Effect.tap(() => + OtelSpan.annotate({ attributes: PwExpectAttrs, value: { assertion } }).pipe(Effect.orDie), + ), ) diff --git a/packages/@overeng/utils/src/node/playwright/page.ts b/packages/@overeng/utils/src/node/playwright/page.ts index c874256d7..c8546180f 100644 --- a/packages/@overeng/utils/src/node/playwright/page.ts +++ b/packages/@overeng/utils/src/node/playwright/page.ts @@ -5,7 +5,16 @@ */ import type { Page } from '@playwright/test' -import { Effect, Fiber } from 'effect' +import { Effect, Fiber, Schema } from 'effect' + +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' import { type PwOpError, tryPw } from './op.ts' import { PwPage } from './tags.ts' @@ -14,6 +23,80 @@ type WaitUntil = NonNullable[1]>['waitUntil'] type LoadState = Parameters[0] type URLMatch = Parameters[0] +const PwPageAttrs = OtelAttrs.defineSync( + Schema.Struct({ + url: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.url' }))), + waitUntil: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.waitUntil' }))), + loadState: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.loadState' }))), + timeoutMs: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.timeout.ms' }))), + urlMatch: Schema.optional(Schema.String.pipe(OtelAttr.key({ key: 'pw.urlMatch' }))), + jitterMsMin: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.jitter.msMin' }))), + jitterMsMax: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.jitter.msMax' }))), + viewportWidth: Schema.optional(Schema.Number.pipe(OtelAttr.key({ key: 'pw.viewport.width' }))), + viewportHeight: Schema.optional( + Schema.Number.pipe(OtelAttr.key({ key: 'pw.viewport.height' })), + ), + screenshotPath: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: 'pw.screenshot.path' })), + ), + screenshotFullPage: Schema.optional( + Schema.Boolean.pipe(OtelAttr.key({ key: 'pw.screenshot.fullPage' })), + ), + }), +) + +const PwPageUrlOperation = OtelOperation.define({ + name: 'pw.page.url', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + }), + label: ({ label }) => label, +}) + +const PwPageIsClosedOperation = OtelOperation.define({ + name: 'pw.page.isClosed', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + }), + label: ({ label }) => label, +}) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + +const annotatePage = ( + value: Partial<{ + url: string + waitUntil: string + loadState: string + timeoutMs: number + urlMatch: string + jitterMsMin: number + jitterMsMax: number + viewportWidth: number + viewportHeight: number + screenshotPath: string + screenshotFullPage: boolean + }>, +) => OtelSpan.annotate({ attributes: PwPageAttrs, value }).pipe(Effect.orDie) + +const urlMatchLabel = (urlMatch: URLMatch) => + typeof urlMatch === 'string' + ? urlMatch + : urlMatch instanceof RegExp + ? urlMatch.source + : 'function' + /** * Navigates to a URL. * @@ -33,9 +116,9 @@ export const goto: (args: { page.goto(url, waitUntil !== undefined ? { waitUntil } : {}).then(() => undefined), }).pipe( Effect.tap(() => - Effect.annotateCurrentSpan({ - 'pw.url': url, - 'pw.waitUntil': waitUntil ?? '', + annotatePage({ + url, + waitUntil: waitUntil ?? '', }), ), ) @@ -53,7 +136,7 @@ export const url: Effect.Effect = Effect.gen(function op: 'pw.page.url', effect: () => Promise.resolve(page.url()), }) -}).pipe(Effect.withSpan('pw.page.url')) +}).pipe(trustedWith(PwPageUrlOperation, { label: 'url' })) /** Waits for a specific load state. */ export const waitForLoadState: (args: { @@ -65,7 +148,7 @@ export const waitForLoadState: (args: { yield* tryPw({ op: 'pw.page.waitForLoadState', effect: () => page.waitForLoadState(state), - }).pipe(Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.loadState': String(state) }))) + }).pipe(Effect.tap(() => annotatePage({ loadState: String(state) }))) }), ) @@ -100,9 +183,9 @@ export const waitForURLChange: (args: { .then(() => undefined), }).pipe( Effect.tap(() => - Effect.annotateCurrentSpan({ - 'pw.waitUntil': waitUntil ?? '', - 'pw.url': currentUrl, + annotatePage({ + waitUntil: waitUntil ?? '', + url: currentUrl, }), ), ) @@ -136,15 +219,10 @@ export const waitForURL: (args: { .then(() => undefined), }).pipe( Effect.tap(() => - Effect.annotateCurrentSpan({ - 'pw.waitUntil': waitUntil ?? '', - 'pw.timeout.ms': timeoutMs ?? 0, - 'pw.urlMatch': - typeof urlMatch === 'string' - ? urlMatch - : urlMatch instanceof RegExp - ? urlMatch.source - : 'function', + annotatePage({ + waitUntil: waitUntil ?? '', + timeoutMs: timeoutMs ?? 0, + urlMatch: urlMatchLabel(urlMatch), }), ), ) @@ -185,7 +263,7 @@ export const waitForTimeout: (args: { yield* tryPw({ op: 'pw.page.waitForTimeout', effect: () => page.waitForTimeout(ms), - }).pipe(Effect.tap(() => Effect.annotateCurrentSpan({ 'pw.timeout.ms': ms }))) + }).pipe(Effect.tap(() => annotatePage({ timeoutMs: ms }))) }), ) @@ -204,21 +282,14 @@ export const jitter: (args?: { const msMax = args?.msMax ?? 420 return waitForTimeout({ ms: Math.floor(Math.random() * (msMax - msMin + 1)) + msMin, - }).pipe( - Effect.tap(() => - Effect.annotateCurrentSpan({ - 'pw.jitter.msMin': msMin, - 'pw.jitter.msMax': msMax, - }), - ), - ) + }).pipe(Effect.tap(() => annotatePage({ jitterMsMin: msMin, jitterMsMax: msMax }))) }) /** Returns whether the page is already closed. */ export const isClosed: Effect.Effect = Effect.gen(function* () { const page = yield* PwPage return page.isClosed() -}).pipe(Effect.withSpan('pw.page.isClosed')) +}).pipe(trustedWith(PwPageIsClosedOperation, { label: 'isClosed' })) /** Sets the page viewport size. */ export const setViewportSize: (args: { @@ -235,9 +306,9 @@ export const setViewportSize: (args: { effect: () => page.setViewportSize({ width, height }), }).pipe( Effect.tap(() => - Effect.annotateCurrentSpan({ - 'pw.viewport.width': width, - 'pw.viewport.height': height, + annotatePage({ + viewportWidth: width, + viewportHeight: height, }), ), ) @@ -310,9 +381,9 @@ export const screenshot: (options?: ScreenshotOptions) => Effect.Effect page.screenshot(options), }).pipe( Effect.tap(() => - Effect.annotateCurrentSpan({ - 'pw.screenshot.path': options?.path ?? '', - 'pw.screenshot.fullPage': options?.fullPage ?? false, + annotatePage({ + screenshotPath: options?.path ?? '', + screenshotFullPage: options?.fullPage ?? false, }), ), ) diff --git a/packages/@overeng/utils/src/node/playwright/step.ts b/packages/@overeng/utils/src/node/playwright/step.ts index 9ec38d552..196655d85 100644 --- a/packages/@overeng/utils/src/node/playwright/step.ts +++ b/packages/@overeng/utils/src/node/playwright/step.ts @@ -5,7 +5,41 @@ */ import { test } from '@playwright/test' -import { Effect, Exit, Function as F } from 'effect' +import { Effect, Exit, Function as F, Schema } from 'effect' + +import { + OtelAttr, + OtelOperation, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + +const PwStepOperation = (name: string) => + OtelOperation.define({ + name, + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + step: Schema.Boolean.pipe(OtelAttr.key({ key: 'pw.step' })), + stepName: Schema.NonEmptyString.pipe(OtelAttr.key({ key: 'pw.step.name' })), + parentSpanTag: Schema.optional( + Schema.String.pipe(OtelAttr.key({ key: 'pw.step.parentSpan._tag' })), + ), + }), + label: ({ label }) => label, + }) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) /** * Runs an Effect inside a Playwright `test.step(...)` boundary. @@ -39,14 +73,11 @@ export const step: { const run = parentSpan._tag === 'Some' ? self.pipe(Effect.withParentSpan(parentSpan.value)) : self const traced = run.pipe( - Effect.withSpan(name, { - attributes: { - 'pw.step': true, - 'pw.step.name': name, - ...(parentSpan._tag === 'Some' - ? { 'pw.step.parentSpan._tag': parentSpan.value._tag } - : {}), - }, + trustedWith(PwStepOperation(name), { + label: name, + step: true, + stepName: name, + ...(parentSpan._tag === 'Some' ? { parentSpanTag: parentSpan.value._tag } : {}), }), ) diff --git a/packages/@overeng/utils/src/node/playwright/wait.ts b/packages/@overeng/utils/src/node/playwright/wait.ts index 3308716a0..7cfbcdeb4 100644 --- a/packages/@overeng/utils/src/node/playwright/wait.ts +++ b/packages/@overeng/utils/src/node/playwright/wait.ts @@ -11,6 +11,15 @@ import { type Duration, Effect, Schedule, Schema } from 'effect' +import { + OtelAttr, + OtelAttrs, + OtelOperation, + OtelSpan, + type OtelAttrEncodeError, + type OtelOperationDefinition, +} from '@overeng/otel-contract' + /** Error thrown when a polling wait operation times out */ export class PwWaitTimeoutError extends Schema.TaggedError()( 'PwWaitTimeoutError', @@ -20,6 +29,39 @@ export class PwWaitTimeoutError extends Schema.TaggedError() }, ) {} +const PwWaitOperation = OtelOperation.define({ + name: 'pw.wait.until', + schema: Schema.Struct({ + label: OtelAttr.drop(Schema.NonEmptyString), + waitLabel: Schema.String.pipe(OtelAttr.key({ key: 'pw.wait.label' })), + pollInterval: Schema.String.pipe(OtelAttr.key({ key: 'pw.wait.pollInterval' })), + timeout: Schema.String.pipe(OtelAttr.key({ key: 'pw.wait.timeout' })), + }), + label: ({ label }) => label, +}) + +const PwWaitAttemptAttrs = OtelAttrs.defineSync( + Schema.Struct({ + attempt: Schema.Number.pipe(OtelAttr.key({ key: 'pw.wait.attempt' })), + }), +) + +const trustOtelContract = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.catchTag('OtelAttrEncodeError', (error) => Effect.die(error))) + +const trustedWith = + ( + operation: OtelOperationDefinition, + attributes: Schema.Schema.Type, + ) => + (effect: Effect.Effect) => + trustOtelContract(operation.with({ attributes, effect })) + +const annotateWaitAttempt = (attempt: number) => + OtelSpan.annotate({ attributes: PwWaitAttemptAttrs, value: { attempt } }).pipe(Effect.orDie) + const hasTag = (error: unknown): error is { _tag: string } => { if (typeof error !== 'object' || error === null) return false return typeof (error as Record)._tag === 'string' @@ -55,17 +97,11 @@ export const until = (args: { const { label, check, pollInterval, timeout, while: while_ } = args return Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - 'pw.wait.label': label, - 'pw.wait.pollInterval': String(pollInterval), - 'pw.wait.timeout': String(timeout), - }) - let attempt = 0 const checkWithTelemetry = Effect.gen(function* () { attempt += 1 - yield* Effect.annotateCurrentSpan({ 'pw.wait.attempt': attempt }) + yield* annotateWaitAttempt(attempt) return yield* check }).pipe( Effect.tapError((error) => @@ -85,5 +121,12 @@ export const until = (args: { onTimeout: () => new PwWaitTimeoutError({ label, timeout: String(timeout) }), }), ) - }).pipe(Effect.withSpan('pw.wait.until')) + }).pipe( + trustedWith(PwWaitOperation, { + label, + waitLabel: label, + pollInterval: String(pollInterval), + timeout: String(timeout), + }), + ) } diff --git a/packages/@overeng/utils/tsconfig.json b/packages/@overeng/utils/tsconfig.json index 0555836f5..3dccc32ab 100644 --- a/packages/@overeng/utils/tsconfig.json +++ b/packages/@overeng/utils/tsconfig.json @@ -44,6 +44,9 @@ }, "include": ["src/**/*"], "references": [ + { + "path": "../otel-contract" + }, { "path": "../utils-dev" } diff --git a/packages/@overeng/utils/tsconfig.json.genie.ts b/packages/@overeng/utils/tsconfig.json.genie.ts index 92aa304f7..3f0dfb2c8 100644 --- a/packages/@overeng/utils/tsconfig.json.genie.ts +++ b/packages/@overeng/utils/tsconfig.json.genie.ts @@ -10,5 +10,5 @@ export default tsconfigJson({ ...packageTsconfigCompilerOptions, }, include: ['src/**/*'], - references: [{ path: '../utils-dev' }], + references: [{ path: '../otel-contract' }, { path: '../utils-dev' }], } satisfies TSConfigArgs) diff --git a/packages/@overeng/workflow-report/nix/build.nix b/packages/@overeng/workflow-report/nix/build.nix index f29ec103e..e32fabcbe 100644 --- a/packages/@overeng/workflow-report/nix/build.nix +++ b/packages/@overeng/workflow-report/nix/build.nix @@ -19,7 +19,7 @@ let # Managed by the repo FOD refresh workflow — do not edit manually. depsBuilds = { "." = { - hash = "sha256-+6FMYB9MQl/cchtJSCUCIHbDHjnhF8xkbGdEt+smSAE="; + hash = "sha256-9QYiKCj1ByN7jxOtmZ+AJ32uxdWAJYbSmuhkCd5Cpi4="; }; }; smokeTestArgs = [ "--help" ]; diff --git a/packages/@overeng/workflow-report/package.json b/packages/@overeng/workflow-report/package.json index 69f8f91b3..d099460c0 100644 --- a/packages/@overeng/workflow-report/package.json +++ b/packages/@overeng/workflow-report/package.json @@ -45,6 +45,7 @@ "source": "package.json.genie.ts", "warning": "DO NOT EDIT - changes will be overwritten", "workspaceClosureDirs": [ + "packages/@overeng/otel-contract", "packages/@overeng/utils", "packages/@overeng/utils-dev", "packages/@overeng/workflow-report" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b8e1eacb..b471b6888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,10 @@ importers: version: 3.2.4(@types/debug@4.1.13)(@types/node@25.3.3)(happy-dom@18.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) packages/@overeng/effect-react: + dependencies: + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract devDependencies: '@overeng/utils': specifier: workspace:^ @@ -424,6 +428,9 @@ importers: '@opentui/react': specifier: 0.1.88 version: 0.1.88(react-devtools-core@7.0.1)(react@19.2.3)(stage-js@1.0.2)(typescript@5.9.3)(web-tree-sitter@0.25.10)(ws@8.20.0) + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@playwright/test': specifier: 1.59.1 version: 1.59.1 @@ -591,6 +598,9 @@ importers: '@overeng/kdl-effect': specifier: workspace:^ version: link:../kdl-effect + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@overeng/tui-react': specifier: workspace:^ version: link:../tui-react @@ -721,6 +731,9 @@ importers: '@overeng/notion-md': specifier: workspace:^ version: link:../notion-md + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@overeng/tui-core': specifier: workspace:^ version: link:../tui-core @@ -818,6 +831,9 @@ importers: '@overeng/notion-md': specifier: workspace:^ version: link:../notion-md + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@overeng/tui-react': specifier: workspace:^ version: link:../tui-react @@ -930,6 +946,9 @@ importers: '@overeng/notion-effect-schema': specifier: workspace:^ version: link:../notion-effect-schema + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -998,6 +1017,9 @@ importers: '@overeng/notion-effect-schema': specifier: workspace:^ version: link:../notion-effect-schema + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@overeng/utils': specifier: workspace:^ version: link:../utils @@ -1131,6 +1153,9 @@ importers: '@overeng/notion-effect-schema': specifier: workspace:^ version: link:../notion-effect-schema + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@playwright/test': specifier: 1.59.1 version: 1.59.1 @@ -1196,6 +1221,24 @@ importers: specifier: 7.3.1 version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + packages/@overeng/otel-contract: + devDependencies: + '@overeng/utils-dev': + specifier: workspace:^ + version: link:../utils-dev + '@types/node': + specifier: 25.3.3 + version: 25.3.3 + effect: + specifier: 3.21.2 + version: 3.21.2 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.3.3)(happy-dom@18.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + packages/@overeng/oxc-config: dependencies: eslint-plugin-storybook: @@ -1232,6 +1275,9 @@ importers: '@myobie/pty': specifier: 0.9.0 version: 0.9.0 + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract devDependencies: '@effect/vitest': specifier: 0.29.0 @@ -1351,6 +1397,9 @@ importers: '@effect/workflow': specifier: ^0.18.0 version: 0.18.0(@effect/experimental@0.60.0(@effect/platform@0.96.1(patch_hash=08d6466db56675b7a32a3a3c64815a5b784f583b310b6758471a97d3db6edd32)(effect@3.21.2))(effect@3.21.2)(ioredis@5.6.1))(@effect/platform@0.96.1(patch_hash=08d6466db56675b7a32a3a3c64815a5b784f583b310b6758471a97d3db6edd32)(effect@3.21.2))(@effect/rpc@0.75.1(@effect/platform@0.96.1(patch_hash=08d6466db56675b7a32a3a3c64815a5b784f583b310b6758471a97d3db6edd32)(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -1666,6 +1715,9 @@ importers: '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 + '@overeng/otel-contract': + specifier: workspace:^ + version: link:../otel-contract effect: specifier: 3.21.2 version: 3.21.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e532c33d7..703df5bc1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,7 @@ packages: - packages/@overeng/notion-effect-schema - packages/@overeng/notion-md - packages/@overeng/notion-react + - packages/@overeng/otel-contract - packages/@overeng/oxc-config - packages/@overeng/pty-effect - packages/@overeng/react-inspector diff --git a/tsconfig.all.json b/tsconfig.all.json index 483944ea7..5aba326e7 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -69,6 +69,9 @@ { "path": "./packages/@overeng/notion-react" }, + { + "path": "./packages/@overeng/otel-contract" + }, { "path": "./packages/@overeng/oxc-config" },