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