@storyshelf/observability 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,46 @@ const storage = createInstrumentedStorage(baseStorage);
25
25
 
26
26
  See the [StoryShelf documentation](https://github.com/GuptaSiddhant/StoryShelf#readme) for configuration details.
27
27
 
28
+ ## Server wiring
29
+
30
+ ```ts
31
+ import { otelLogMixin } from "@storyshelf/observability";
32
+ import { initObservabilityFromEnv } from "@storyshelf/observability/node";
33
+
34
+ // Noop unless OTEL_EXPORTER_OTLP_ENDPOINT is set. Call before createShelfApp.
35
+ const observability = await initObservabilityFromEnv();
36
+
37
+ const app = createShelfApp({
38
+ database,
39
+ storage,
40
+ testing: { runner: captureRunner },
41
+ logger: createShelfLogger({ mixin: otelLogMixin }),
42
+ observability,
43
+ config: { scratchDir: dataDir },
44
+ });
45
+
46
+ await app.lifecycle.setup();
47
+ // on SIGTERM: await app.lifecycle.teardown(); (flushes the SDK)
48
+ ```
49
+
50
+ `storyshelf server init` scaffolds exactly this preamble. Two entrypoints by design: the root is `@opentelemetry/api`
51
+ only (cross-runtime, Deno-safe); `./node` holds the Node SDK lifecycle and
52
+ must never be imported under Deno (`OTEL_DENO=true` uses Deno's native OTEL
53
+ instead).
54
+
55
+ ## What it exports
56
+
57
+ - `initObservabilityFromEnv`, `resolveObservabilityConfig` (`./node` / config) — env-driven SDK lifecycle.
58
+ - `createHttpMiddleware` — `@hono/otel` server spans plus `storyshelf.req_id` / `enduser.id` (mounted by `createShelfApp`).
59
+ - `createInstrumentedDatabase` / `createInstrumentedStorage` — transparent `db.*` / `storage.*` span + metric wrappers (identity, lifecycle, and tables delegate through).
60
+ - `withSpan`, `currentTraceparent`, `injectTraceContext`, `parentContext` — span and W3C propagation helpers.
61
+ - `captureMetrics`, `dbMetrics`, `storageMetrics` — memoized instruments (`capture.job.duration`, `capture.jobs.completed/failed`, operation durations).
62
+ - `otelLogMixin` — pino mixin injecting `trace_id`/`span_id` (logs stay on stdout; no OTLP log exporter by design).
63
+
64
+ Core (`@storyshelf/core`) uses `@opentelemetry/api` directly for pipeline
65
+ spans (`capture.job`, phase spans, `http.client`) and never imports this
66
+ package — that one-way boundary is load-bearing (see ADR 0015 §5).
67
+
28
68
  ## License
29
69
 
30
70
  MIT
package/dist/config.mjs CHANGED
@@ -50,9 +50,13 @@ function resolveObservabilityConfig(env = process.env, serviceVersion) {
50
50
  metricsIntervalMs: parseInterval(env["STORYSHELF_OTEL_METRICS_INTERVAL_MS"], DEFAULT_METRICS_INTERVAL_MS)
51
51
  };
52
52
  }
53
- /** Package version injected at build via __PKG_VERSION__. */
53
+ /**
54
+ * Package version injected at build via the `__PKG_VERSION__` define. Read as a bare identifier
55
+ * (defines don't replace `globalThis` member reads); the `typeof` guard keeps source-condition
56
+ * runs, where the identifier is undeclared, from throwing.
57
+ */
54
58
  function packageVersion() {
55
- return globalThis.__PKG_VERSION__;
59
+ return "0.7.0";
56
60
  }
57
61
  //#endregion
58
62
  export { resolveObservabilityConfig };
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Environment-driven observability configuration.\n *\n * Standard `OTEL_*` variables win; `STORYSHELF_OTEL_*` covers StoryShelf\n * specifics. When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset the SDK stays a\n * noop (zero overhead, hermetic tests). Deno runtimes are always noop here —\n * Deno's built-in OTEL (`OTEL_DENO=true`) bridges `npm:@opentelemetry/api`\n * natively, so a NodeSDK must never start under Deno.\n */\n\n/** Resolved observability configuration for the Node SDK entrypoint. */\nexport interface ObservabilityConfig {\n /** Whether to start the SDK (false = noop handle). */\n enabled: boolean;\n /** OTLP/HTTP endpoint, e.g. `http://collector:4318`. */\n endpoint?: string;\n /** Extra OTLP headers (`OTEL_EXPORTER_OTLP_HEADERS`, `k=v,k2=v2`). */\n headers?: Record<string, string>;\n /** Resource `service.name`. */\n serviceName: string;\n /** Resource `service.version`. */\n serviceVersion?: string;\n /** Metric export interval in milliseconds. */\n metricsIntervalMs: number;\n}\n\nconst DEFAULT_SERVICE_NAME = \"storyshelf\";\nconst DEFAULT_METRICS_INTERVAL_MS = 60_000;\n\n/** Whether the current runtime is Deno (native OTEL owns the SDK there). */\nfunction isDenoRuntime(env: NodeJS.ProcessEnv): boolean {\n if (env[\"OTEL_DENO\"] === \"true\") {\n return true;\n }\n return (globalThis as { Deno?: unknown }).Deno !== undefined;\n}\n\n/** Parse one `k=v` pair into the headers record. */\nfunction parseHeaderPair(pair: string, headers: Record<string, string>): void {\n const separator = pair.indexOf(\"=\");\n if (separator === -1) {\n return;\n }\n const key = pair.slice(0, separator).trim();\n const value = pair.slice(separator + 1).trim();\n if (key) {\n headers[key] = value;\n }\n}\n\n/** Parse `k=v,k2=v2` header syntax into a record. */\nfunction parseHeaders(raw: string | undefined): Record<string, string> | undefined {\n if (!raw) {\n return undefined;\n }\n const headers: Record<string, string> = {};\n for (const pair of raw.split(\",\")) {\n parseHeaderPair(pair, headers);\n }\n return Object.keys(headers).length === 0 ? undefined : headers;\n}\n\n/** Parse a positive integer env value with a fallback. */\nfunction parseInterval(raw: string | undefined, fallback: number): number {\n const parsed = Number(raw);\n if (!raw || !Number.isFinite(parsed) || parsed <= 0) {\n return fallback;\n }\n return Math.trunc(parsed);\n}\n\n/**\n * Resolve the observability configuration from the environment.\n *\n * @param env - Environment record (defaults to `process.env`).\n * @param serviceVersion - Version for the resource (defaults to `__PKG_VERSION__`).\n * @returns The resolved config; `enabled` is false without an endpoint.\n */\nexport function resolveObservabilityConfig(\n env: NodeJS.ProcessEnv = process.env,\n serviceVersion?: string,\n): ObservabilityConfig {\n const endpoint = env[\"OTEL_EXPORTER_OTLP_ENDPOINT\"];\n const sdkDisabled = env[\"OTEL_SDK_DISABLED\"] === \"true\";\n const explicit = env[\"STORYSHELF_OTEL_ENABLED\"];\n const enabled =\n !sdkDisabled && !isDenoRuntime(env) && (explicit ? explicit === \"true\" : Boolean(endpoint));\n return {\n enabled,\n endpoint,\n headers: parseHeaders(env[\"OTEL_EXPORTER_OTLP_HEADERS\"]),\n serviceName: env[\"OTEL_SERVICE_NAME\"] ?? DEFAULT_SERVICE_NAME,\n serviceVersion: serviceVersion ?? packageVersion(),\n metricsIntervalMs: parseInterval(\n env[\"STORYSHELF_OTEL_METRICS_INTERVAL_MS\"],\n DEFAULT_METRICS_INTERVAL_MS,\n ),\n };\n}\n\n/** Package version injected at build via __PKG_VERSION__. */\nfunction packageVersion(): string | undefined {\n return (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__;\n}\n"],"mappings":";;;;AA0BA,MAAM,uBAAuB;AAC7B,MAAM,8BAA8B;;AAGpC,SAAS,cAAc,KAAiC;CACtD,IAAI,IAAI,iBAAiB,QACvB,OAAO;CAET,OAAQ,WAAkC,SAAS,KAAA;AACrD;;AAGA,SAAS,gBAAgB,MAAc,SAAuC;CAC5E,MAAM,YAAY,KAAK,QAAQ,GAAG;CAClC,IAAI,cAAc,IAChB;CAEF,MAAM,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC,KAAK;CAC1C,MAAM,QAAQ,KAAK,MAAM,YAAY,CAAC,CAAC,CAAC,KAAK;CAC7C,IAAI,KACF,QAAQ,OAAO;AAEnB;;AAGA,SAAS,aAAa,KAA6D;CACjF,IAAI,CAAC,KACH;CAEF,MAAM,UAAkC,CAAC;CACzC,KAAK,MAAM,QAAQ,IAAI,MAAM,GAAG,GAC9B,gBAAgB,MAAM,OAAO;CAE/B,OAAO,OAAO,KAAK,OAAO,CAAC,CAAC,WAAW,IAAI,KAAA,IAAY;AACzD;;AAGA,SAAS,cAAc,KAAyB,UAA0B;CACxE,MAAM,SAAS,OAAO,GAAG;CACzB,IAAI,CAAC,OAAO,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GAChD,OAAO;CAET,OAAO,KAAK,MAAM,MAAM;AAC1B;;;;;;;;AASA,SAAgB,2BACd,MAAyB,QAAQ,KACjC,gBACqB;CACrB,MAAM,WAAW,IAAI;CACrB,MAAM,cAAc,IAAI,yBAAyB;CACjD,MAAM,WAAW,IAAI;CAGrB,OAAO;EACL,SAFA,CAAC,eAAe,CAAC,cAAc,GAAG,MAAM,WAAW,aAAa,SAAS,QAAQ,QAAQ;EAGzF;EACA,SAAS,aAAa,IAAI,6BAA6B;EACvD,aAAa,IAAI,wBAAwB;EACzC,gBAAgB,kBAAkB,eAAe;EACjD,mBAAmB,cACjB,IAAI,wCACJ,2BACF;CACF;AACF;;AAGA,SAAS,iBAAqC;CAC5C,OAAQ,WAAuD;AACjE"}
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Environment-driven observability configuration.\n *\n * Standard `OTEL_*` variables win; `STORYSHELF_OTEL_*` covers StoryShelf\n * specifics. When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset the SDK stays a\n * noop (zero overhead, hermetic tests). Deno runtimes are always noop here —\n * Deno's built-in OTEL (`OTEL_DENO=true`) bridges `npm:@opentelemetry/api`\n * natively, so a NodeSDK must never start under Deno.\n */\n\n/** Resolved observability configuration for the Node SDK entrypoint. */\nexport interface ObservabilityConfig {\n /** Whether to start the SDK (false = noop handle). */\n enabled: boolean;\n /** OTLP/HTTP endpoint, e.g. `http://collector:4318`. */\n endpoint?: string;\n /** Extra OTLP headers (`OTEL_EXPORTER_OTLP_HEADERS`, `k=v,k2=v2`). */\n headers?: Record<string, string>;\n /** Resource `service.name`. */\n serviceName: string;\n /** Resource `service.version`. */\n serviceVersion?: string;\n /** Metric export interval in milliseconds. */\n metricsIntervalMs: number;\n}\n\nconst DEFAULT_SERVICE_NAME = \"storyshelf\";\nconst DEFAULT_METRICS_INTERVAL_MS = 60_000;\n\n/** Whether the current runtime is Deno (native OTEL owns the SDK there). */\nfunction isDenoRuntime(env: NodeJS.ProcessEnv): boolean {\n if (env[\"OTEL_DENO\"] === \"true\") {\n return true;\n }\n return (globalThis as { Deno?: unknown }).Deno !== undefined;\n}\n\n/** Parse one `k=v` pair into the headers record. */\nfunction parseHeaderPair(pair: string, headers: Record<string, string>): void {\n const separator = pair.indexOf(\"=\");\n if (separator === -1) {\n return;\n }\n const key = pair.slice(0, separator).trim();\n const value = pair.slice(separator + 1).trim();\n if (key) {\n headers[key] = value;\n }\n}\n\n/** Parse `k=v,k2=v2` header syntax into a record. */\nfunction parseHeaders(raw: string | undefined): Record<string, string> | undefined {\n if (!raw) {\n return undefined;\n }\n const headers: Record<string, string> = {};\n for (const pair of raw.split(\",\")) {\n parseHeaderPair(pair, headers);\n }\n return Object.keys(headers).length === 0 ? undefined : headers;\n}\n\n/** Parse a positive integer env value with a fallback. */\nfunction parseInterval(raw: string | undefined, fallback: number): number {\n const parsed = Number(raw);\n if (!raw || !Number.isFinite(parsed) || parsed <= 0) {\n return fallback;\n }\n return Math.trunc(parsed);\n}\n\n/**\n * Resolve the observability configuration from the environment.\n *\n * @param env - Environment record (defaults to `process.env`).\n * @param serviceVersion - Version for the resource (defaults to `__PKG_VERSION__`).\n * @returns The resolved config; `enabled` is false without an endpoint.\n */\nexport function resolveObservabilityConfig(\n env: NodeJS.ProcessEnv = process.env,\n serviceVersion?: string,\n): ObservabilityConfig {\n const endpoint = env[\"OTEL_EXPORTER_OTLP_ENDPOINT\"];\n const sdkDisabled = env[\"OTEL_SDK_DISABLED\"] === \"true\";\n const explicit = env[\"STORYSHELF_OTEL_ENABLED\"];\n const enabled =\n !sdkDisabled && !isDenoRuntime(env) && (explicit ? explicit === \"true\" : Boolean(endpoint));\n return {\n enabled,\n endpoint,\n headers: parseHeaders(env[\"OTEL_EXPORTER_OTLP_HEADERS\"]),\n serviceName: env[\"OTEL_SERVICE_NAME\"] ?? DEFAULT_SERVICE_NAME,\n serviceVersion: serviceVersion ?? packageVersion(),\n metricsIntervalMs: parseInterval(\n env[\"STORYSHELF_OTEL_METRICS_INTERVAL_MS\"],\n DEFAULT_METRICS_INTERVAL_MS,\n ),\n };\n}\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/**\n * Package version injected at build via the `__PKG_VERSION__` define. Read as a bare identifier\n * (defines don't replace `globalThis` member reads); the `typeof` guard keeps source-condition\n * runs, where the identifier is undeclared, from throwing.\n */\nfunction packageVersion(): string | undefined {\n return typeof __PKG_VERSION__ === \"string\" ? __PKG_VERSION__ : undefined;\n}\n"],"mappings":";;;;AA0BA,MAAM,uBAAuB;AAC7B,MAAM,8BAA8B;;AAGpC,SAAS,cAAc,KAAiC;CACtD,IAAI,IAAI,iBAAiB,QACvB,OAAO;CAET,OAAQ,WAAkC,SAAS,KAAA;AACrD;;AAGA,SAAS,gBAAgB,MAAc,SAAuC;CAC5E,MAAM,YAAY,KAAK,QAAQ,GAAG;CAClC,IAAI,cAAc,IAChB;CAEF,MAAM,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC,KAAK;CAC1C,MAAM,QAAQ,KAAK,MAAM,YAAY,CAAC,CAAC,CAAC,KAAK;CAC7C,IAAI,KACF,QAAQ,OAAO;AAEnB;;AAGA,SAAS,aAAa,KAA6D;CACjF,IAAI,CAAC,KACH;CAEF,MAAM,UAAkC,CAAC;CACzC,KAAK,MAAM,QAAQ,IAAI,MAAM,GAAG,GAC9B,gBAAgB,MAAM,OAAO;CAE/B,OAAO,OAAO,KAAK,OAAO,CAAC,CAAC,WAAW,IAAI,KAAA,IAAY;AACzD;;AAGA,SAAS,cAAc,KAAyB,UAA0B;CACxE,MAAM,SAAS,OAAO,GAAG;CACzB,IAAI,CAAC,OAAO,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GAChD,OAAO;CAET,OAAO,KAAK,MAAM,MAAM;AAC1B;;;;;;;;AASA,SAAgB,2BACd,MAAyB,QAAQ,KACjC,gBACqB;CACrB,MAAM,WAAW,IAAI;CACrB,MAAM,cAAc,IAAI,yBAAyB;CACjD,MAAM,WAAW,IAAI;CAGrB,OAAO;EACL,SAFA,CAAC,eAAe,CAAC,cAAc,GAAG,MAAM,WAAW,aAAa,SAAS,QAAQ,QAAQ;EAGzF;EACA,SAAS,aAAa,IAAI,6BAA6B;EACvD,aAAa,IAAI,wBAAwB;EACzC,gBAAgB,kBAAkB,eAAe;EACjD,mBAAmB,cACjB,IAAI,wCACJ,2BACF;CACF;AACF;;;;;;AASA,SAAS,iBAAqC;CAC5C,OAAA;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"instrument-db.mjs","names":[],"sources":["../src/instrument-db.ts"],"sourcesContent":["/**\n * Database adapter wrapper: one span + duration metric per operation.\n *\n * Span names are `db.<operation>` with the table as an attribute (never in\n * the name). Adapter identity (`metadata`, `lifecycle`, `tables`) and the\n * host-bound logger delegate straight through, so health/setup behave\n * identically with or without observability.\n */\nimport type { DatabaseAdapter, ListOptions, Tables } from \"@storyshelf/core/adapter/database\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { addTiming } from \"@storyshelf/core/utils\";\nimport { getTableName, type SQL, type Table } from \"drizzle-orm\";\nimport { dbMetrics } from \"./metrics.ts\";\nimport { withSpan } from \"./tracing.ts\";\n\n/**\n * Wrap a database adapter with per-operation spans and metrics.\n *\n * @param db - The adapter to wrap (never mutated).\n * @returns A transparent adapter emitting `db.*` telemetry.\n */\nexport function createInstrumentedDatabase(db: DatabaseAdapter): DatabaseAdapter {\n return new InstrumentedDatabase(db);\n}\n\nclass InstrumentedDatabase implements DatabaseAdapter {\n constructor(private readonly inner: DatabaseAdapter) {}\n\n get metadata(): DatabaseAdapter[\"metadata\"] {\n return this.inner.metadata;\n }\n\n get lifecycle(): DatabaseAdapter[\"lifecycle\"] {\n return this.inner.lifecycle;\n }\n\n get tables(): Tables {\n return this.inner.tables;\n }\n\n setLogger(logger: Logger): void {\n this.inner.setLogger?.(logger);\n }\n\n async insert<T extends Table>(table: T, values: T[\"$inferInsert\"]): Promise<T[\"$inferSelect\"]> {\n return await track(\"db.insert\", table, async () => await this.inner.insert(table, values));\n }\n\n async update<T extends Table>(\n table: T,\n id: string,\n values: Partial<T[\"$inferInsert\"]>,\n ): Promise<T[\"$inferSelect\"]> {\n return await track(\"db.update\", table, async () => await this.inner.update(table, id, values));\n }\n\n async get<T extends Table>(table: T, id: string): Promise<T[\"$inferSelect\"] | null> {\n return await track(\"db.get\", table, async () => await this.inner.get(table, id));\n }\n\n async remove(table: Table, id: string): Promise<void> {\n await track(\"db.remove\", table, async () => {\n await this.inner.remove(table, id);\n });\n }\n\n async list<T extends Table>(table: T, opts?: ListOptions): Promise<T[\"$inferSelect\"][]> {\n return await track(\"db.list\", table, async () => await this.inner.list(table, opts));\n }\n\n async count(table: Table, where?: SQL): Promise<number> {\n return await track(\"db.count\", table, async () => await this.inner.count(table, where));\n }\n\n async all<T>(query: SQL): Promise<T[]> {\n return await trackQuery(\"db.all\", async () => await this.inner.all<T>(query));\n }\n}\n\n/** Run a table operation inside a span with a duration measurement. */\nasync function track<T>(operation: string, table: Table, fn: () => Promise<T>): Promise<T> {\n const tableName = getTableName(table);\n const start = performance.now();\n try {\n return await withSpan(operation, fn, { \"db.table\": tableName });\n } finally {\n const durationMs = performance.now() - start;\n addTiming(\"db\", durationMs);\n dbMetrics().operationDuration.record(durationMs, {\n \"db.operation\": operation,\n \"db.table\": tableName,\n });\n }\n}\n\n/** Run a table-less query inside a span with a duration measurement. */\nasync function trackQuery<T>(operation: string, fn: () => Promise<T>): Promise<T> {\n const start = performance.now();\n try {\n return await withSpan(operation, fn);\n } finally {\n const durationMs = performance.now() - start;\n addTiming(\"db\", durationMs);\n dbMetrics().operationDuration.record(durationMs, {\n \"db.operation\": operation,\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAqBA,SAAgB,2BAA2B,IAAsC;CAC/E,OAAO,IAAI,qBAAqB,EAAE;AACpC;AAEA,IAAM,uBAAN,MAAsD;CACvB;CAA7B,YAAY,OAAyC;EAAxB,KAAA,QAAA;CAAyB;CAEtD,IAAI,WAAwC;EAC1C,OAAO,KAAK,MAAM;CACpB;CAEA,IAAI,YAA0C;EAC5C,OAAO,KAAK,MAAM;CACpB;CAEA,IAAI,SAAiB;EACnB,OAAO,KAAK,MAAM;CACpB;CAEA,UAAU,QAAsB;EAC9B,KAAK,MAAM,YAAY,MAAM;CAC/B;CAEA,MAAM,OAAwB,OAAU,QAAuD;EAC7F,OAAO,MAAM,MAAM,aAAa,OAAO,YAAY,MAAM,KAAK,MAAM,OAAO,OAAO,MAAM,CAAC;CAC3F;CAEA,MAAM,OACJ,OACA,IACA,QAC4B;EAC5B,OAAO,MAAM,MAAM,aAAa,OAAO,YAAY,MAAM,KAAK,MAAM,OAAO,OAAO,IAAI,MAAM,CAAC;CAC/F;CAEA,MAAM,IAAqB,OAAU,IAA+C;EAClF,OAAO,MAAM,MAAM,UAAU,OAAO,YAAY,MAAM,KAAK,MAAM,IAAI,OAAO,EAAE,CAAC;CACjF;CAEA,MAAM,OAAO,OAAc,IAA2B;EACpD,MAAM,MAAM,aAAa,OAAO,YAAY;GAC1C,MAAM,KAAK,MAAM,OAAO,OAAO,EAAE;EACnC,CAAC;CACH;CAEA,MAAM,KAAsB,OAAU,MAAkD;EACtF,OAAO,MAAM,MAAM,WAAW,OAAO,YAAY,MAAM,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC;CACrF;CAEA,MAAM,MAAM,OAAc,OAA8B;EACtD,OAAO,MAAM,MAAM,YAAY,OAAO,YAAY,MAAM,KAAK,MAAM,MAAM,OAAO,KAAK,CAAC;CACxF;CAEA,MAAM,IAAO,OAA0B;EACrC,OAAO,MAAM,WAAW,UAAU,YAAY,MAAM,KAAK,MAAM,IAAO,KAAK,CAAC;CAC9E;AACF;;AAGA,eAAe,MAAS,WAAmB,OAAc,IAAkC;CACzF,MAAM,YAAY,aAAa,KAAK;CACpC,MAAM,QAAQ,YAAY,IAAI;CAC9B,IAAI;EACF,OAAO,MAAM,SAAS,WAAW,IAAI,EAAE,YAAY,UAAU,CAAC;CAChE,UAAU;EACR,MAAM,aAAa,YAAY,IAAI,IAAI;EACvC,UAAU,MAAM,UAAU;EAC1B,UAAU,CAAC,CAAC,kBAAkB,OAAO,YAAY;GAC/C,gBAAgB;GAChB,YAAY;EACd,CAAC;CACH;AACF;;AAGA,eAAe,WAAc,WAAmB,IAAkC;CAChF,MAAM,QAAQ,YAAY,IAAI;CAC9B,IAAI;EACF,OAAO,MAAM,SAAS,WAAW,EAAE;CACrC,UAAU;EACR,MAAM,aAAa,YAAY,IAAI,IAAI;EACvC,UAAU,MAAM,UAAU;EAC1B,UAAU,CAAC,CAAC,kBAAkB,OAAO,YAAY,EAC/C,gBAAgB,UAClB,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"instrument-db.mjs","names":[],"sources":["../src/instrument-db.ts"],"sourcesContent":["/**\n * Database adapter wrapper: one span + duration metric per operation.\n *\n * Span names are `db.<operation>` with the table as an attribute (never in\n * the name). Adapter identity (`metadata`, `lifecycle`, `tables`) and the\n * host-bound logger delegate straight through, so health/setup behave\n * identically with or without observability.\n */\nimport type { DatabaseAdapter, ListOptions, Tables } from \"@storyshelf/core/adapter/database\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { addTiming } from \"@storyshelf/core/utils\";\nimport { getTableName, type SQL, type Table } from \"drizzle-orm\";\nimport { dbMetrics } from \"./metrics.ts\";\nimport { withSpan } from \"./tracing.ts\";\n\n/**\n * Wrap a database adapter with per-operation spans and metrics.\n *\n * @param db - The adapter to wrap (never mutated).\n * @returns A transparent adapter emitting `db.*` telemetry.\n */\nexport function createInstrumentedDatabase(db: DatabaseAdapter): DatabaseAdapter {\n return new InstrumentedDatabase(db);\n}\n\nclass InstrumentedDatabase implements DatabaseAdapter {\n private readonly inner: DatabaseAdapter;\n\n constructor(inner: DatabaseAdapter) {\n this.inner = inner;\n }\n\n get metadata(): DatabaseAdapter[\"metadata\"] {\n return this.inner.metadata;\n }\n\n get lifecycle(): DatabaseAdapter[\"lifecycle\"] {\n return this.inner.lifecycle;\n }\n\n get tables(): Tables {\n return this.inner.tables;\n }\n\n setLogger(logger: Logger): void {\n this.inner.setLogger?.(logger);\n }\n\n async insert<T extends Table>(table: T, values: T[\"$inferInsert\"]): Promise<T[\"$inferSelect\"]> {\n return await track(\"db.insert\", table, async () => await this.inner.insert(table, values));\n }\n\n async update<T extends Table>(\n table: T,\n id: string,\n values: Partial<T[\"$inferInsert\"]>,\n ): Promise<T[\"$inferSelect\"]> {\n return await track(\"db.update\", table, async () => await this.inner.update(table, id, values));\n }\n\n async get<T extends Table>(table: T, id: string): Promise<T[\"$inferSelect\"] | null> {\n return await track(\"db.get\", table, async () => await this.inner.get(table, id));\n }\n\n async remove(table: Table, id: string): Promise<void> {\n await track(\"db.remove\", table, async () => {\n await this.inner.remove(table, id);\n });\n }\n\n async list<T extends Table>(table: T, opts?: ListOptions): Promise<T[\"$inferSelect\"][]> {\n return await track(\"db.list\", table, async () => await this.inner.list(table, opts));\n }\n\n async count(table: Table, where?: SQL): Promise<number> {\n return await track(\"db.count\", table, async () => await this.inner.count(table, where));\n }\n\n async all<T>(query: SQL): Promise<T[]> {\n return await trackQuery(\"db.all\", async () => await this.inner.all<T>(query));\n }\n}\n\n/** Run a table operation inside a span with a duration measurement. */\nasync function track<T>(operation: string, table: Table, fn: () => Promise<T>): Promise<T> {\n const tableName = getTableName(table);\n const start = performance.now();\n try {\n return await withSpan(operation, fn, { \"db.table\": tableName });\n } finally {\n const durationMs = performance.now() - start;\n addTiming(\"db\", durationMs);\n dbMetrics().operationDuration.record(durationMs, {\n \"db.operation\": operation,\n \"db.table\": tableName,\n });\n }\n}\n\n/** Run a table-less query inside a span with a duration measurement. */\nasync function trackQuery<T>(operation: string, fn: () => Promise<T>): Promise<T> {\n const start = performance.now();\n try {\n return await withSpan(operation, fn);\n } finally {\n const durationMs = performance.now() - start;\n addTiming(\"db\", durationMs);\n dbMetrics().operationDuration.record(durationMs, {\n \"db.operation\": operation,\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAqBA,SAAgB,2BAA2B,IAAsC;CAC/E,OAAO,IAAI,qBAAqB,EAAE;AACpC;AAEA,IAAM,uBAAN,MAAsD;CACpD;CAEA,YAAY,OAAwB;EAClC,KAAK,QAAQ;CACf;CAEA,IAAI,WAAwC;EAC1C,OAAO,KAAK,MAAM;CACpB;CAEA,IAAI,YAA0C;EAC5C,OAAO,KAAK,MAAM;CACpB;CAEA,IAAI,SAAiB;EACnB,OAAO,KAAK,MAAM;CACpB;CAEA,UAAU,QAAsB;EAC9B,KAAK,MAAM,YAAY,MAAM;CAC/B;CAEA,MAAM,OAAwB,OAAU,QAAuD;EAC7F,OAAO,MAAM,MAAM,aAAa,OAAO,YAAY,MAAM,KAAK,MAAM,OAAO,OAAO,MAAM,CAAC;CAC3F;CAEA,MAAM,OACJ,OACA,IACA,QAC4B;EAC5B,OAAO,MAAM,MAAM,aAAa,OAAO,YAAY,MAAM,KAAK,MAAM,OAAO,OAAO,IAAI,MAAM,CAAC;CAC/F;CAEA,MAAM,IAAqB,OAAU,IAA+C;EAClF,OAAO,MAAM,MAAM,UAAU,OAAO,YAAY,MAAM,KAAK,MAAM,IAAI,OAAO,EAAE,CAAC;CACjF;CAEA,MAAM,OAAO,OAAc,IAA2B;EACpD,MAAM,MAAM,aAAa,OAAO,YAAY;GAC1C,MAAM,KAAK,MAAM,OAAO,OAAO,EAAE;EACnC,CAAC;CACH;CAEA,MAAM,KAAsB,OAAU,MAAkD;EACtF,OAAO,MAAM,MAAM,WAAW,OAAO,YAAY,MAAM,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC;CACrF;CAEA,MAAM,MAAM,OAAc,OAA8B;EACtD,OAAO,MAAM,MAAM,YAAY,OAAO,YAAY,MAAM,KAAK,MAAM,MAAM,OAAO,KAAK,CAAC;CACxF;CAEA,MAAM,IAAO,OAA0B;EACrC,OAAO,MAAM,WAAW,UAAU,YAAY,MAAM,KAAK,MAAM,IAAO,KAAK,CAAC;CAC9E;AACF;;AAGA,eAAe,MAAS,WAAmB,OAAc,IAAkC;CACzF,MAAM,YAAY,aAAa,KAAK;CACpC,MAAM,QAAQ,YAAY,IAAI;CAC9B,IAAI;EACF,OAAO,MAAM,SAAS,WAAW,IAAI,EAAE,YAAY,UAAU,CAAC;CAChE,UAAU;EACR,MAAM,aAAa,YAAY,IAAI,IAAI;EACvC,UAAU,MAAM,UAAU;EAC1B,UAAU,CAAC,CAAC,kBAAkB,OAAO,YAAY;GAC/C,gBAAgB;GAChB,YAAY;EACd,CAAC;CACH;AACF;;AAGA,eAAe,WAAc,WAAmB,IAAkC;CAChF,MAAM,QAAQ,YAAY,IAAI;CAC9B,IAAI;EACF,OAAO,MAAM,SAAS,WAAW,EAAE;CACrC,UAAU;EACR,MAAM,aAAa,YAAY,IAAI,IAAI;EACvC,UAAU,MAAM,UAAU;EAC1B,UAAU,CAAC,CAAC,kBAAkB,OAAO,YAAY,EAC/C,gBAAgB,UAClB,CAAC;CACH;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"instrument-storage.mjs","names":[],"sources":["../src/instrument-storage.ts"],"sourcesContent":["/**\n * Storage adapter wrapper: one span + duration metric per operation.\n *\n * Span names are `storage.<operation>` with the path as an attribute.\n * Metrics carry the operation only (paths are high-cardinality and stay\n * out of metric attributes). Identity, lifecycle, and logger delegate\n * straight through.\n */\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { addTiming } from \"@storyshelf/core/utils\";\nimport type { Buffer } from \"node:buffer\";\nimport type { Readable } from \"node:stream\";\nimport { storageMetrics } from \"./metrics.ts\";\nimport { withSpan } from \"./tracing.ts\";\n\n/**\n * Wrap a storage adapter with per-operation spans and metrics.\n *\n * @param storage - The adapter to wrap (never mutated).\n * @returns A transparent adapter emitting `storage.*` telemetry.\n */\nexport function createInstrumentedStorage(storage: StorageAdapter): StorageAdapter {\n return new InstrumentedStorage(storage);\n}\n\nclass InstrumentedStorage implements StorageAdapter {\n constructor(private readonly inner: StorageAdapter) {}\n\n get metadata(): StorageAdapter[\"metadata\"] {\n return this.inner.metadata;\n }\n\n get lifecycle(): StorageAdapter[\"lifecycle\"] {\n return this.inner.lifecycle;\n }\n\n setLogger(logger: Logger): void {\n this.inner.setLogger?.(logger);\n }\n\n async read(path: string): Promise<Buffer> {\n return await track(\"storage.read\", path, async () => await this.inner.read(path));\n }\n\n async write(path: string, data: Buffer): Promise<void> {\n await track(\"storage.write\", path, async () => {\n await this.inner.write(path, data);\n });\n }\n\n async delete(path: string): Promise<void> {\n await track(\"storage.delete\", path, async () => {\n await this.inner.delete(path);\n });\n }\n\n async exists(path: string): Promise<boolean> {\n return await track(\"storage.exists\", path, async () => await this.inner.exists(path));\n }\n\n async list(prefix: string): Promise<string[]> {\n return await track(\"storage.list\", prefix, async () => await this.inner.list(prefix));\n }\n\n async writeStream(path: string, stream: Readable): Promise<void> {\n await track(\"storage.writeStream\", path, async () => {\n await this.inner.writeStream(path, stream);\n });\n }\n\n async readStream(path: string): Promise<Readable> {\n return await track(\"storage.readStream\", path, async () => await this.inner.readStream(path));\n }\n}\n\n/** Run a storage operation inside a span with a duration measurement. */\nasync function track<T>(operation: string, path: string, fn: () => Promise<T>): Promise<T> {\n const start = performance.now();\n try {\n return await withSpan(operation, fn, { \"storage.path\": path });\n } finally {\n const durationMs = performance.now() - start;\n addTiming(\"storage\", durationMs);\n storageMetrics().operationDuration.record(durationMs, {\n \"storage.operation\": operation,\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;AAsBA,SAAgB,0BAA0B,SAAyC;CACjF,OAAO,IAAI,oBAAoB,OAAO;AACxC;AAEA,IAAM,sBAAN,MAAoD;CACrB;CAA7B,YAAY,OAAwC;EAAvB,KAAA,QAAA;CAAwB;CAErD,IAAI,WAAuC;EACzC,OAAO,KAAK,MAAM;CACpB;CAEA,IAAI,YAAyC;EAC3C,OAAO,KAAK,MAAM;CACpB;CAEA,UAAU,QAAsB;EAC9B,KAAK,MAAM,YAAY,MAAM;CAC/B;CAEA,MAAM,KAAK,MAA+B;EACxC,OAAO,MAAM,MAAM,gBAAgB,MAAM,YAAY,MAAM,KAAK,MAAM,KAAK,IAAI,CAAC;CAClF;CAEA,MAAM,MAAM,MAAc,MAA6B;EACrD,MAAM,MAAM,iBAAiB,MAAM,YAAY;GAC7C,MAAM,KAAK,MAAM,MAAM,MAAM,IAAI;EACnC,CAAC;CACH;CAEA,MAAM,OAAO,MAA6B;EACxC,MAAM,MAAM,kBAAkB,MAAM,YAAY;GAC9C,MAAM,KAAK,MAAM,OAAO,IAAI;EAC9B,CAAC;CACH;CAEA,MAAM,OAAO,MAAgC;EAC3C,OAAO,MAAM,MAAM,kBAAkB,MAAM,YAAY,MAAM,KAAK,MAAM,OAAO,IAAI,CAAC;CACtF;CAEA,MAAM,KAAK,QAAmC;EAC5C,OAAO,MAAM,MAAM,gBAAgB,QAAQ,YAAY,MAAM,KAAK,MAAM,KAAK,MAAM,CAAC;CACtF;CAEA,MAAM,YAAY,MAAc,QAAiC;EAC/D,MAAM,MAAM,uBAAuB,MAAM,YAAY;GACnD,MAAM,KAAK,MAAM,YAAY,MAAM,MAAM;EAC3C,CAAC;CACH;CAEA,MAAM,WAAW,MAAiC;EAChD,OAAO,MAAM,MAAM,sBAAsB,MAAM,YAAY,MAAM,KAAK,MAAM,WAAW,IAAI,CAAC;CAC9F;AACF;;AAGA,eAAe,MAAS,WAAmB,MAAc,IAAkC;CACzF,MAAM,QAAQ,YAAY,IAAI;CAC9B,IAAI;EACF,OAAO,MAAM,SAAS,WAAW,IAAI,EAAE,gBAAgB,KAAK,CAAC;CAC/D,UAAU;EACR,MAAM,aAAa,YAAY,IAAI,IAAI;EACvC,UAAU,WAAW,UAAU;EAC/B,eAAe,CAAC,CAAC,kBAAkB,OAAO,YAAY,EACpD,qBAAqB,UACvB,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"instrument-storage.mjs","names":[],"sources":["../src/instrument-storage.ts"],"sourcesContent":["/**\n * Storage adapter wrapper: one span + duration metric per operation.\n *\n * Span names are `storage.<operation>` with the path as an attribute.\n * Metrics carry the operation only (paths are high-cardinality and stay\n * out of metric attributes). Identity, lifecycle, and logger delegate\n * straight through.\n */\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { addTiming } from \"@storyshelf/core/utils\";\nimport type { Buffer } from \"node:buffer\";\nimport type { Readable } from \"node:stream\";\nimport { storageMetrics } from \"./metrics.ts\";\nimport { withSpan } from \"./tracing.ts\";\n\n/**\n * Wrap a storage adapter with per-operation spans and metrics.\n *\n * @param storage - The adapter to wrap (never mutated).\n * @returns A transparent adapter emitting `storage.*` telemetry.\n */\nexport function createInstrumentedStorage(storage: StorageAdapter): StorageAdapter {\n return new InstrumentedStorage(storage);\n}\n\nclass InstrumentedStorage implements StorageAdapter {\n private readonly inner: StorageAdapter;\n\n constructor(inner: StorageAdapter) {\n this.inner = inner;\n }\n\n get metadata(): StorageAdapter[\"metadata\"] {\n return this.inner.metadata;\n }\n\n get lifecycle(): StorageAdapter[\"lifecycle\"] {\n return this.inner.lifecycle;\n }\n\n setLogger(logger: Logger): void {\n this.inner.setLogger?.(logger);\n }\n\n async read(path: string): Promise<Buffer> {\n return await track(\"storage.read\", path, async () => await this.inner.read(path));\n }\n\n async write(path: string, data: Buffer): Promise<void> {\n await track(\"storage.write\", path, async () => {\n await this.inner.write(path, data);\n });\n }\n\n async delete(path: string): Promise<void> {\n await track(\"storage.delete\", path, async () => {\n await this.inner.delete(path);\n });\n }\n\n async exists(path: string): Promise<boolean> {\n return await track(\"storage.exists\", path, async () => await this.inner.exists(path));\n }\n\n async list(prefix: string): Promise<string[]> {\n return await track(\"storage.list\", prefix, async () => await this.inner.list(prefix));\n }\n\n async writeStream(path: string, stream: Readable): Promise<void> {\n await track(\"storage.writeStream\", path, async () => {\n await this.inner.writeStream(path, stream);\n });\n }\n\n async readStream(path: string): Promise<Readable> {\n return await track(\"storage.readStream\", path, async () => await this.inner.readStream(path));\n }\n}\n\n/** Run a storage operation inside a span with a duration measurement. */\nasync function track<T>(operation: string, path: string, fn: () => Promise<T>): Promise<T> {\n const start = performance.now();\n try {\n return await withSpan(operation, fn, { \"storage.path\": path });\n } finally {\n const durationMs = performance.now() - start;\n addTiming(\"storage\", durationMs);\n storageMetrics().operationDuration.record(durationMs, {\n \"storage.operation\": operation,\n });\n }\n}\n"],"mappings":";;;;;;;;;;;;;AAsBA,SAAgB,0BAA0B,SAAyC;CACjF,OAAO,IAAI,oBAAoB,OAAO;AACxC;AAEA,IAAM,sBAAN,MAAoD;CAClD;CAEA,YAAY,OAAuB;EACjC,KAAK,QAAQ;CACf;CAEA,IAAI,WAAuC;EACzC,OAAO,KAAK,MAAM;CACpB;CAEA,IAAI,YAAyC;EAC3C,OAAO,KAAK,MAAM;CACpB;CAEA,UAAU,QAAsB;EAC9B,KAAK,MAAM,YAAY,MAAM;CAC/B;CAEA,MAAM,KAAK,MAA+B;EACxC,OAAO,MAAM,MAAM,gBAAgB,MAAM,YAAY,MAAM,KAAK,MAAM,KAAK,IAAI,CAAC;CAClF;CAEA,MAAM,MAAM,MAAc,MAA6B;EACrD,MAAM,MAAM,iBAAiB,MAAM,YAAY;GAC7C,MAAM,KAAK,MAAM,MAAM,MAAM,IAAI;EACnC,CAAC;CACH;CAEA,MAAM,OAAO,MAA6B;EACxC,MAAM,MAAM,kBAAkB,MAAM,YAAY;GAC9C,MAAM,KAAK,MAAM,OAAO,IAAI;EAC9B,CAAC;CACH;CAEA,MAAM,OAAO,MAAgC;EAC3C,OAAO,MAAM,MAAM,kBAAkB,MAAM,YAAY,MAAM,KAAK,MAAM,OAAO,IAAI,CAAC;CACtF;CAEA,MAAM,KAAK,QAAmC;EAC5C,OAAO,MAAM,MAAM,gBAAgB,QAAQ,YAAY,MAAM,KAAK,MAAM,KAAK,MAAM,CAAC;CACtF;CAEA,MAAM,YAAY,MAAc,QAAiC;EAC/D,MAAM,MAAM,uBAAuB,MAAM,YAAY;GACnD,MAAM,KAAK,MAAM,YAAY,MAAM,MAAM;EAC3C,CAAC;CACH;CAEA,MAAM,WAAW,MAAiC;EAChD,OAAO,MAAM,MAAM,sBAAsB,MAAM,YAAY,MAAM,KAAK,MAAM,WAAW,IAAI,CAAC;CAC9F;AACF;;AAGA,eAAe,MAAS,WAAmB,MAAc,IAAkC;CACzF,MAAM,QAAQ,YAAY,IAAI;CAC9B,IAAI;EACF,OAAO,MAAM,SAAS,WAAW,IAAI,EAAE,gBAAgB,KAAK,CAAC;CAC/D,UAAU;EACR,MAAM,aAAa,YAAY,IAAI,IAAI;EACvC,UAAU,WAAW,UAAU;EAC/B,eAAe,CAAC,CAAC,kBAAkB,OAAO,YAAY,EACpD,qBAAqB,UACvB,CAAC;CACH;AACF"}
@@ -1,6 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { redactSecrets } from "@storyshelf/core/utils";
4
5
  import { context, trace } from "@opentelemetry/api";
5
6
  import { httpInstrumentationMiddleware } from "@hono/otel";
6
7
  //#region src/middleware.ts
@@ -16,11 +17,17 @@ import { httpInstrumentationMiddleware } from "@hono/otel";
16
17
  function enrichSpan(c) {
17
18
  const span = trace.getSpan(context.active());
18
19
  if (!span) return;
20
+ redactUrl(c, span);
19
21
  const reqId = c.get("requestId");
20
22
  if (reqId) span.setAttribute("storyshelf.req_id", reqId);
21
23
  const userId = c.get("userId");
22
24
  if (userId) span.setAttribute("enduser.id", userId);
23
25
  }
26
+ /** A signed Storybook URL carries its token in the path (ADR 0007): the span must not keep it. */
27
+ function redactUrl(c, span) {
28
+ const url = redactSecrets(c.req.url);
29
+ if (url !== c.req.url) span.setAttribute("url.full", url);
30
+ }
24
31
  /**
25
32
  * Create the instrumenting middleware.
26
33
  *
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.mjs","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["/**\n * Hono HTTP middleware: `@hono/otel` server spans plus StoryShelf context.\n *\n * `@hono/otel` owns the `http.server` span (W3C extraction, route-template\n * naming, status attributes, request-duration metrics). This wrapper adds\n * `storyshelf.req_id` and `enduser.id` after downstream handlers run — still\n * inside the span, since `@hono/otel` finalizes after `next()` returns.\n */\nimport { httpInstrumentationMiddleware } from \"@hono/otel\";\nimport { context, trace } from \"@opentelemetry/api\";\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport type { HttpMiddlewareOptions } from \"./types.ts\";\n\n/** Copy request/user ids from the Hono context onto the active server span. */\nfunction enrichSpan(c: Context): void {\n const span = trace.getSpan(context.active());\n if (!span) {\n return;\n }\n const reqId = c.get(\"requestId\") as string | undefined;\n if (reqId) {\n span.setAttribute(\"storyshelf.req_id\", reqId);\n }\n const userId = c.get(\"userId\") as string | null | undefined;\n if (userId) {\n span.setAttribute(\"enduser.id\", userId);\n }\n}\n\n/**\n * Create the instrumenting middleware.\n *\n * Mount directly after Hono's `requestId()` so `storyshelf.req_id` resolves;\n * `enduser.id` resolves when `storeScope` runs downstream.\n *\n * @param options - Service name/version for span resources.\n * @returns Hono middleware producing one `http.server` span per request.\n */\nexport function createHttpMiddleware(options: HttpMiddlewareOptions = {}): MiddlewareHandler {\n const otel = httpInstrumentationMiddleware({\n serviceName: options.serviceName ?? \"storyshelf\",\n serviceVersion: options.serviceVersion,\n });\n return async (c, next) => {\n return await otel(c, async () => {\n await next();\n enrichSpan(c);\n });\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AAcA,SAAS,WAAW,GAAkB;CACpC,MAAM,OAAO,MAAM,QAAQ,QAAQ,OAAO,CAAC;CAC3C,IAAI,CAAC,MACH;CAEF,MAAM,QAAQ,EAAE,IAAI,WAAW;CAC/B,IAAI,OACF,KAAK,aAAa,qBAAqB,KAAK;CAE9C,MAAM,SAAS,EAAE,IAAI,QAAQ;CAC7B,IAAI,QACF,KAAK,aAAa,cAAc,MAAM;AAE1C;;;;;;;;;;AAWA,SAAgB,qBAAqB,UAAiC,CAAC,GAAsB;CAC3F,MAAM,OAAO,8BAA8B;EACzC,aAAa,QAAQ,eAAe;EACpC,gBAAgB,QAAQ;CAC1B,CAAC;CACD,OAAO,OAAO,GAAG,SAAS;EACxB,OAAO,MAAM,KAAK,GAAG,YAAY;GAC/B,MAAM,KAAK;GACX,WAAW,CAAC;EACd,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"middleware.mjs","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["/**\n * Hono HTTP middleware: `@hono/otel` server spans plus StoryShelf context.\n *\n * `@hono/otel` owns the `http.server` span (W3C extraction, route-template\n * naming, status attributes, request-duration metrics). This wrapper adds\n * `storyshelf.req_id` and `enduser.id` after downstream handlers run — still\n * inside the span, since `@hono/otel` finalizes after `next()` returns.\n */\nimport { httpInstrumentationMiddleware } from \"@hono/otel\";\nimport { context, trace, type Span } from \"@opentelemetry/api\";\nimport { redactSecrets } from \"@storyshelf/core/utils\";\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport type { HttpMiddlewareOptions } from \"./types.ts\";\n\n/** Copy request/user ids from the Hono context onto the active server span. */\nfunction enrichSpan(c: Context): void {\n const span = trace.getSpan(context.active());\n if (!span) {\n return;\n }\n redactUrl(c, span);\n const reqId = c.get(\"requestId\") as string | undefined;\n if (reqId) {\n span.setAttribute(\"storyshelf.req_id\", reqId);\n }\n const userId = c.get(\"userId\") as string | null | undefined;\n if (userId) {\n span.setAttribute(\"enduser.id\", userId);\n }\n}\n\n/** A signed Storybook URL carries its token in the path (ADR 0007): the span must not keep it. */\nfunction redactUrl(c: Context, span: Span): void {\n const url = redactSecrets(c.req.url);\n if (url !== c.req.url) {\n span.setAttribute(\"url.full\", url);\n }\n}\n\n/**\n * Create the instrumenting middleware.\n *\n * Mount directly after Hono's `requestId()` so `storyshelf.req_id` resolves;\n * `enduser.id` resolves when `storeScope` runs downstream.\n *\n * @param options - Service name/version for span resources.\n * @returns Hono middleware producing one `http.server` span per request.\n */\nexport function createHttpMiddleware(options: HttpMiddlewareOptions = {}): MiddlewareHandler {\n const otel = httpInstrumentationMiddleware({\n serviceName: options.serviceName ?? \"storyshelf\",\n serviceVersion: options.serviceVersion,\n });\n return async (c, next) => {\n return await otel(c, async () => {\n await next();\n enrichSpan(c);\n });\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAeA,SAAS,WAAW,GAAkB;CACpC,MAAM,OAAO,MAAM,QAAQ,QAAQ,OAAO,CAAC;CAC3C,IAAI,CAAC,MACH;CAEF,UAAU,GAAG,IAAI;CACjB,MAAM,QAAQ,EAAE,IAAI,WAAW;CAC/B,IAAI,OACF,KAAK,aAAa,qBAAqB,KAAK;CAE9C,MAAM,SAAS,EAAE,IAAI,QAAQ;CAC7B,IAAI,QACF,KAAK,aAAa,cAAc,MAAM;AAE1C;;AAGA,SAAS,UAAU,GAAY,MAAkB;CAC/C,MAAM,MAAM,cAAc,EAAE,IAAI,GAAG;CACnC,IAAI,QAAQ,EAAE,IAAI,KAChB,KAAK,aAAa,YAAY,GAAG;AAErC;;;;;;;;;;AAWA,SAAgB,qBAAqB,UAAiC,CAAC,GAAsB;CAC3F,MAAM,OAAO,8BAA8B;EACzC,aAAa,QAAQ,eAAe;EACpC,gBAAgB,QAAQ;CAC1B,CAAC;CACD,OAAO,OAAO,GAAG,SAAS;EACxB,OAAO,MAAM,KAAK,GAAG,YAAY;GAC/B,MAAM,KAAK;GACX,WAAW,CAAC;EACd,CAAC;CACH;AACF"}
@@ -6,8 +6,8 @@ import { context, propagation } from "@opentelemetry/api";
6
6
  /**
7
7
  * W3C trace-context propagation helpers.
8
8
  *
9
- * Queue payloads carry the `traceparent` string (see `CaptureJob`) so a
10
- * remote worker can continue the enqueueing request's trace; outbound HTTP
9
+ * Queue payloads carry the `traceparent` string (see `CaptureJob`) so the
10
+ * consumer can continue the enqueueing request's trace; outbound HTTP
11
11
  * injects the same header via {@link injectTraceContext}.
12
12
  */
13
13
  /** W3C traceparent header name. */
@@ -1 +1 @@
1
- {"version":3,"file":"propagate.mjs","names":[],"sources":["../src/propagate.ts"],"sourcesContent":["/**\n * W3C trace-context propagation helpers.\n *\n * Queue payloads carry the `traceparent` string (see `CaptureJob`) so a\n * remote worker can continue the enqueueing request's trace; outbound HTTP\n * injects the same header via {@link injectTraceContext}.\n */\nimport { context, propagation, type Context } from \"@opentelemetry/api\";\n\n/** W3C traceparent header name. */\nexport const TRACEPARENT_HEADER = \"traceparent\";\n\n/**\n * Resolve the parent context from an inbound `traceparent` value.\n *\n * @param traceparent - W3C value from a queue payload; undefined keeps context.\n * @returns The extracted remote context, or the active context when absent.\n */\nexport function parentContext(traceparent?: string): Context {\n if (!traceparent) {\n return context.active();\n }\n return propagation.extract(context.active(), { [TRACEPARENT_HEADER]: traceparent });\n}\n\n/** Inject the active span context into a mutable header record. */\nexport function injectTraceContext(headers: Record<string, string>): void {\n propagation.inject(context.active(), headers);\n}\n\n/**\n * Read the current W3C `traceparent` value for queue payloads.\n *\n * @returns The header value, or undefined when no span is active.\n */\nexport function currentTraceparent(): string | undefined {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier);\n return carrier[TRACEPARENT_HEADER];\n}\n"],"mappings":";;;;;;;;;;;;;AAUA,MAAa,qBAAqB;;;;;;;AAQlC,SAAgB,cAAc,aAA+B;CAC3D,IAAI,CAAC,aACH,OAAO,QAAQ,OAAO;CAExB,OAAO,YAAY,QAAQ,QAAQ,OAAO,GAAG,GAAG,qBAAqB,YAAY,CAAC;AACpF;;AAGA,SAAgB,mBAAmB,SAAuC;CACxE,YAAY,OAAO,QAAQ,OAAO,GAAG,OAAO;AAC9C;;;;;;AAOA,SAAgB,qBAAyC;CACvD,MAAM,UAAkC,CAAC;CACzC,YAAY,OAAO,QAAQ,OAAO,GAAG,OAAO;CAC5C,OAAO,QAAQ;AACjB"}
1
+ {"version":3,"file":"propagate.mjs","names":[],"sources":["../src/propagate.ts"],"sourcesContent":["/**\n * W3C trace-context propagation helpers.\n *\n * Queue payloads carry the `traceparent` string (see `CaptureJob`) so the\n * consumer can continue the enqueueing request's trace; outbound HTTP\n * injects the same header via {@link injectTraceContext}.\n */\nimport { context, propagation, type Context } from \"@opentelemetry/api\";\n\n/** W3C traceparent header name. */\nexport const TRACEPARENT_HEADER = \"traceparent\";\n\n/**\n * Resolve the parent context from an inbound `traceparent` value.\n *\n * @param traceparent - W3C value from a queue payload; undefined keeps context.\n * @returns The extracted remote context, or the active context when absent.\n */\nexport function parentContext(traceparent?: string): Context {\n if (!traceparent) {\n return context.active();\n }\n return propagation.extract(context.active(), { [TRACEPARENT_HEADER]: traceparent });\n}\n\n/** Inject the active span context into a mutable header record. */\nexport function injectTraceContext(headers: Record<string, string>): void {\n propagation.inject(context.active(), headers);\n}\n\n/**\n * Read the current W3C `traceparent` value for queue payloads.\n *\n * @returns The header value, or undefined when no span is active.\n */\nexport function currentTraceparent(): string | undefined {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier);\n return carrier[TRACEPARENT_HEADER];\n}\n"],"mappings":";;;;;;;;;;;;;AAUA,MAAa,qBAAqB;;;;;;;AAQlC,SAAgB,cAAc,aAA+B;CAC3D,IAAI,CAAC,aACH,OAAO,QAAQ,OAAO;CAExB,OAAO,YAAY,QAAQ,QAAQ,OAAO,GAAG,GAAG,qBAAqB,YAAY,CAAC;AACpF;;AAGA,SAAgB,mBAAmB,SAAuC;CACxE,YAAY,OAAO,QAAQ,OAAO,GAAG,OAAO;AAC9C;;;;;;AAOA,SAAgB,qBAAyC;CACvD,MAAM,UAAkC,CAAC;CACzC,YAAY,OAAO,QAAQ,OAAO,GAAG,OAAO;CAC5C,OAAO,QAAQ;AACjB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storyshelf/observability",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "StoryShelf observability: OpenTelemetry tracing, metrics, and log correlation over @storyshelf/core adapters.",
5
5
  "keywords": [
6
6
  "metrics",
@@ -65,7 +65,7 @@
65
65
  "@opentelemetry/sdk-metrics": "^2.11.0",
66
66
  "@opentelemetry/sdk-node": "^0.222.0",
67
67
  "@opentelemetry/semantic-conventions": "^1.43.0",
68
- "@storyshelf/core": "0.6.1",
68
+ "@storyshelf/core": "0.7.0",
69
69
  "drizzle-orm": "^0.44.0",
70
70
  "hono": "^4.11.0"
71
71
  },
@@ -84,6 +84,9 @@
84
84
  "typescript": "^7.0.2",
85
85
  "vitest": "^5.0.0"
86
86
  },
87
+ "engines": {
88
+ "node": ">=24"
89
+ },
87
90
  "jsr": {
88
91
  "runtimeCompat": {
89
92
  "node": true,