diff --git a/.changeset/smoodev-1067d-otel-native-client.md b/.changeset/smoodev-1067d-otel-native-client.md new file mode 100644 index 0000000..9b09c19 --- /dev/null +++ b/.changeset/smoodev-1067d-otel-native-client.md @@ -0,0 +1,22 @@ +--- +'@smooai/observability': minor +'@smooai/observability-otel': minor +--- + +OTel-first node Client (SMOODEV-1067d). + +The Node Client no longer wraps a Smoo-native HTTP transport — it emits to OpenTelemetry natively. Every `captureException` / `captureMessage` becomes a span event on the active OTel span (or a synthetic one if none is active), with `SpanStatusCode.ERROR` for exceptions and OTLP-shaped attributes (`enduser.id`, `enduser.org_id`, `service.version`, `deployment.environment.name`, `smoo.tag.*`, `smoo.event_id`, `smoo.level`). The OTel SDK handles batching, retry, and wire format; the Smoo SDK does not run a parallel HTTP pipeline on Node. + +`@smooai/logger` is now optional. The Smoo SDK has no compile-time dependency on it. When present, its CONTEXT global feeds OTel baggage (see `@smooai/observability-otel`). When absent, the OTel ambient context (W3C trace context propagation, baggage) is the single source of correlation truth — winston / pino / bunyan / console users get the same trace-id flowing through logs, traces, and Smoo error groups by reading `readOtelCorrelation()`. + +Breaking changes (`@smooai/observability` 0.3 → 0.4): + +- `makeNodeTransport` (re-exported from the `node` entry) removed — no longer needed; OTel SDK is the transport. +- `Client._registerTransport` is now a no-op on Node when a capture handler is registered (which happens by default in `Client.init`). Browser is unchanged. +- New seam `Client._registerCaptureHandler(handler | null)` for advanced consumers who want to plug in their own non-OTel capture path. + +Breaking changes (`@smooai/observability-otel` 0.1 → 0.2): + +- `bridgeClientToOtel()` removed. There's nothing to bridge — the Smoo Client already emits to OTel natively on Node. `setupOtelSdk()` and `readOtelCorrelation()` remain. + +Tests: 33 green on core (was 24), 5 on otel package. Typecheck + build clean. diff --git a/packages/core/package.json b/packages/core/package.json index 48881b4..d8f6c8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,7 +43,12 @@ "typecheck": "tsc --noEmit", "lint": "echo \"(lint stub — biome/eslint TBD)\"" }, + "dependencies": { + "@opentelemetry/api": "^1.9.0" + }, "devDependencies": { + "@opentelemetry/context-async-hooks": "^1.30.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", "@types/node": "^22", "tsup": "^8.4.0", "typescript": "^5.6.0", diff --git a/packages/core/src/__tests__/otel-capture.test.ts b/packages/core/src/__tests__/otel-capture.test.ts new file mode 100644 index 0000000..7623771 --- /dev/null +++ b/packages/core/src/__tests__/otel-capture.test.ts @@ -0,0 +1,145 @@ +import { context, ROOT_CONTEXT, SpanStatusCode, trace } from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Client } from '../client'; +import { _resetOtelCaptureForTests, registerOtelCapture } from '../node/otel-capture'; + +const exporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], +}); +trace.setGlobalTracerProvider(provider); +const cm = new AsyncHooksContextManager(); +cm.enable(); +context.setGlobalContextManager(cm); + +describe('OTel-native captureException (node)', () => { + beforeEach(() => { + Client.init({ dsn: 'https://ingest.example/wh/o/t' }); + _resetOtelCaptureForTests(); + registerOtelCapture(); + exporter.reset(); + }); + + afterEach(() => { + _resetOtelCaptureForTests(); + }); + + it('records the exception on the active span and marks status ERROR', () => { + const tracer = trace.getTracer('test'); + tracer.startActiveSpan('handler', (span) => { + try { + Client.captureException(new Error('boom')); + } finally { + span.end(); + } + }); + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.status.code).toBe(SpanStatusCode.ERROR); + expect(spans[0]!.events.map((e) => e.name)).toContain('exception'); + }); + + it('mints a synthetic span named observability.captureException when none active', () => { + context.with(ROOT_CONTEXT, () => { + Client.captureException(new Error('no-context boom')); + }); + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + expect(spans[0]!.name).toBe('observability.captureException'); + expect(spans[0]!.status.code).toBe(SpanStatusCode.ERROR); + }); + + it('stamps the Smoo event id as smoo.event_id on the span', () => { + const tracer = trace.getTracer('test'); + let eventId: string | undefined; + tracer.startActiveSpan('handler', (span) => { + eventId = Client.captureException(new Error('x')); + span.end(); + }); + const span = exporter.getFinishedSpans()[0]!; + expect(span.attributes['smoo.event_id']).toBe(eventId); + }); + + it('propagates Smoo tags as smoo.tag.* attributes', () => { + const tracer = trace.getTracer('test'); + tracer.startActiveSpan('handler', (span) => { + Client.captureException(new Error('x'), { tags: { source: 'unit', tier: 'free' } }); + span.end(); + }); + const span = exporter.getFinishedSpans()[0]!; + expect(span.attributes['smoo.tag.source']).toBe('unit'); + expect(span.attributes['smoo.tag.tier']).toBe('free'); + }); + + it('propagates Scope user as enduser.* attributes', () => { + Client.setUser({ id: 'u1', orgId: 'org1', sessionId: 's1' }); + const tracer = trace.getTracer('test'); + tracer.startActiveSpan('handler', (span) => { + Client.captureException(new Error('with user')); + span.end(); + }); + const span = exporter.getFinishedSpans()[0]!; + expect(span.attributes['enduser.id']).toBe('u1'); + expect(span.attributes['enduser.org_id']).toBe('org1'); + expect(span.attributes['enduser.session_id']).toBe('s1'); + // Cleanup so other tests don't see this user. + Client.setUser(undefined); + }); + + it('captureMessage adds a smoo.message span event without flipping status to ERROR', () => { + const tracer = trace.getTracer('test'); + tracer.startActiveSpan('handler', (span) => { + Client.captureMessage('hello', 'info'); + span.end(); + }); + const span = exporter.getFinishedSpans()[0]!; + expect(span.events.map((e) => e.name)).toContain('smoo.message'); + // Default status is UNSET (0); ERROR is 2. + expect(span.status.code).not.toBe(SpanStatusCode.ERROR); + }); + + it("captureMessage with level='error' flips status to ERROR", () => { + const tracer = trace.getTracer('test'); + tracer.startActiveSpan('handler', (span) => { + Client.captureMessage('this failed', 'error'); + span.end(); + }); + const span = exporter.getFinishedSpans()[0]!; + expect(span.status.code).toBe(SpanStatusCode.ERROR); + }); + + it('is idempotent — registering twice does not double-capture', () => { + registerOtelCapture(); + registerOtelCapture(); + const tracer = trace.getTracer('test'); + tracer.startActiveSpan('handler', (span) => { + Client.captureException(new Error('once')); + span.end(); + }); + const span = exporter.getFinishedSpans()[0]!; + const exceptionEvents = span.events.filter((e) => e.name === 'exception'); + expect(exceptionEvents).toHaveLength(1); + }); + + it('does not call the HTTP transport when capture handler is registered', async () => { + let transportCalled = 0; + Client._registerTransport(async () => { + transportCalled++; + }); + // Register capture handler AFTER transport — capture handler wins. + _resetOtelCaptureForTests(); + registerOtelCapture(); + const tracer = trace.getTracer('test'); + tracer.startActiveSpan('handler', (span) => { + Client.captureException(new Error('routed-to-otel')); + span.end(); + }); + // Allow microtask queue to drain. + await new Promise((r) => setImmediate(r)); + expect(transportCalled).toBe(0); + const spans = exporter.getFinishedSpans(); + expect(spans).toHaveLength(1); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 21a7089..38cc8cf 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -6,23 +6,30 @@ const SDK_NAME = '@smooai/observability'; const SDK_VERSION = '0.1.0'; /** - * Singleton client used by both browser and Node entry points. The transport - * + capture-handler integrations are wired in by the runtime-specific entry - * (`src/browser/index.ts`, `src/node/index.ts`). - * - * This file is intentionally minimal until the SDK implementation lands — - * see SMOODEV-1067 follow-up pearls. + * Native per-runtime capture handler. When registered, `captureException` / + * `captureMessage` route the prepared event through it and SKIP the HTTP + * transport — used by the node runtime to emit directly to OpenTelemetry + * span events (no parallel Smoo-native batched fetch). Browser keeps the + * transport path because OTel browser SDK is too heavy for customer-facing + * sites. + */ +export type CaptureHandler = (event: ObservabilityEvent, raw: { error?: unknown; message?: string; extra?: { tags?: Record } }) => void; + +/** + * Singleton client used by both browser and Node entry points. The + * transport (browser) or native capture handler (node) is wired in by the + * runtime-specific entry (`src/browser/index.ts`, `src/node/index.ts`). */ class _Client { private options: ClientOptions | null = null; private runtime: Runtime = typeof window === 'undefined' ? 'node' : 'browser'; private transport: ((batch: ObservabilityEvent[]) => Promise) | null = null; + private captureHandler: CaptureHandler | null = null; init(options: ClientOptions): void { this.options = options; - // Capture-handler registration happens in the runtime entry point - // (browser/index.ts or node/index.ts), which calls `_registerTransport` - // and binds globals like window.onerror / process events. + // Wiring (transport for browser, OTel-native capture for node) happens + // in the runtime-specific entry's init wrapper. } _isInitialized(): boolean { @@ -37,6 +44,16 @@ class _Client { this.transport = t; } + /** + * Register a runtime-native capture path. When set, captureException / + * captureMessage route through this handler INSTEAD of the HTTP transport + * — node uses this to write directly to OpenTelemetry span events so the + * Smoo SDK speaks OTel natively. Calling with `null` un-registers. + */ + _registerCaptureHandler(handler: CaptureHandler | null): void { + this.captureHandler = handler; + } + setUser(user: ObservabilityEvent['user']): void { getCurrentScope().setUser(user); } @@ -62,7 +79,16 @@ class _Client { sdk: { name: SDK_NAME, version: SDK_VERSION, runtime: this.runtime }, }); const final = this.options.beforeSend ? this.options.beforeSend(event) : event; - if (final && this.transport) { + if (!final) return eventId; + if (this.captureHandler) { + try { + this.captureHandler(final, { error, extra }); + } catch { + /* swallow — observability must not throw */ + } + return eventId; + } + if (this.transport) { // Fire-and-forget; transport handles batching/retry. void this.transport([final]).catch(() => { /* swallow — observability must not throw */ @@ -84,7 +110,16 @@ class _Client { sdk: { name: SDK_NAME, version: SDK_VERSION, runtime: this.runtime }, }); const final = this.options.beforeSend ? this.options.beforeSend(event) : event; - if (final && this.transport) { + if (!final) return eventId; + if (this.captureHandler) { + try { + this.captureHandler(final, { message }); + } catch { + /* swallow */ + } + return eventId; + } + if (this.transport) { void this.transport([final]).catch(() => {}); } return eventId; diff --git a/packages/core/src/node/index.ts b/packages/core/src/node/index.ts index f6a374d..afefafd 100644 --- a/packages/core/src/node/index.ts +++ b/packages/core/src/node/index.ts @@ -1,55 +1,46 @@ /** * Node entry — Lambda / long-running Node services. * - * `Client.init` here: - * - Spins up a node Transport (fetch + keepalive; no Beacon) - * - Registers `uncaughtException` / `unhandledRejection` handlers - * - Wires SIGTERM / SIGINT / beforeExit flushing so a Lambda container - * shutdown drains the in-memory queue + * **OTel-first**: `Client.init` on Node wires the OpenTelemetry-native + * capture path (`registerOtelCapture`) — every captured exception becomes + * a span event on the active OTel span (or a synthetic one) with status + * ERROR and OTLP-shaped attributes. The OpenTelemetry SDK handles + * batching, retry, and wire format; the Smoo SDK does NOT spin up its own + * HTTP transport on Node. * - * The Hono middleware is exported separately so consumers wire it on their - * app explicitly. Browser-only integrations (DOM breadcrumbs etc.) are NOT - * imported here so the node bundle stays small. + * For consumers who haven't initialized OTel yet (no global TracerProvider), + * the OTel API quietly no-ops — events are dropped rather than crashing. + * Use `@smooai/observability-otel/setupOtelSdk()` (recommended) or your own + * OTel NodeSDK bootstrap before calling `Client.init`. + * + * The Hono middleware + process-level error handlers are exported separately + * so consumers wire them on their app explicitly. Browser-only integrations + * (DOM breadcrumbs etc.) are NOT imported here so the node bundle stays small. + * + * `@smooai/logger` is optional. When present, its CONTEXT global feeds OTel + * baggage (handled elsewhere — see `@smooai/observability-otel`). When + * absent, OTel ambient context is the single source of correlation truth. */ import { Client } from '../client'; import { registerNodeGlobalHandlers } from './global-handlers'; -import { makeNodeTransport } from './transport'; +import { registerOtelCapture } from './otel-capture'; export { Client, Scope, withScope, getCurrentScope } from '../index'; export * from '../types'; export { parseStack } from '../stack-parser'; export { registerNodeGlobalHandlers, _resetNodeGlobalHandlersForTests } from './global-handlers'; -export { makeNodeTransport } from './transport'; +export { registerOtelCapture, _resetOtelCaptureForTests } from './otel-capture'; export { observabilityMiddleware } from './middleware'; export type { ObservabilityMiddlewareOptions } from './middleware'; -// Auto-wire on init — mirrors the browser entry's behavior so consumers only -// need to call `Client.init({ dsn, environment, release })`. Set -// `autoInstrumentation: false` to opt out of process error handlers (e.g. -// when the host app wants to install its own). +// Auto-wire on init — Node is OTel-first. Set `autoInstrumentation: false` +// to opt out of process error handlers; the OTel capture path is always +// registered when the SDK is initialized. const originalInit = Client.init.bind(Client); Client.init = (options) => { originalInit(options); - const transport = makeNodeTransport(options); - Client._registerTransport(async (batch) => { - for (const evt of batch) transport.enqueue(evt); - }); + registerOtelCapture(); if (options.autoInstrumentation !== false) { - registerNodeGlobalHandlers({ - exitOnUncaught: false, - flush: (timeoutMs) => { - const flush = transport.flush(); - if (!timeoutMs) return flush; - // Race the flush against a hard timeout so SIGTERM doesn't - // stall the container shutdown. - return Promise.race([ - flush, - new Promise((resolve) => { - const t = setTimeout(resolve, timeoutMs); - t.unref?.(); - }), - ]); - }, - }); + registerNodeGlobalHandlers({ exitOnUncaught: false }); } }; diff --git a/packages/core/src/node/otel-capture.ts b/packages/core/src/node/otel-capture.ts new file mode 100644 index 0000000..9c5c881 --- /dev/null +++ b/packages/core/src/node/otel-capture.ts @@ -0,0 +1,102 @@ +/** + * OTel-native capture handler for the node runtime. + * + * Registered as the Client's CaptureHandler from `node/index.ts`. Replaces + * the Smoo-native HTTP transport on Node — every captured exception / + * message becomes a span event on the active OpenTelemetry span (or a + * synthetic one if no span is active), with `SpanStatusCode.ERROR` for + * exceptions and OTLP-shaped attributes for tags / user / release. The + * OTel SDK handles batching, retry, and wire format; the Smoo SDK doesn't + * need its own HTTP pipeline on Node. + * + * Browser stays on the Smoo-native transport (OTel browser SDK is too + * heavy for customer-facing bundles). The Client itself doesn't know about + * either runtime — the runtime entry wires the appropriate path. + */ + +import { type Attributes, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { CaptureHandler, _Client } from '../client'; +import { Client } from '../client'; +import type { ObservabilityEvent } from '../types'; + +interface RegisterOptions { + /** OTel tracer name. Defaults to 'smooai.observability'. */ + tracerName?: string; +} + +let installed = false; + +export function registerOtelCapture(opts: RegisterOptions = {}): void { + if (installed) return; + installed = true; + + const tracer = trace.getTracer(opts.tracerName ?? 'smooai.observability'); + + const handler: CaptureHandler = (event, raw) => { + const active = trace.getActiveSpan(); + if (active) { + recordOnSpan(active, event, raw); + return; + } + // No active span — mint a synthetic one so the error still surfaces + // in the trace. Background workers (cron, queue consumers) hit this + // path when they capture errors outside any HTTP request context. + const span = tracer.startSpan(event.exception?.length ? 'observability.captureException' : 'observability.captureMessage'); + try { + recordOnSpan(span, event, raw); + } finally { + span.end(); + } + }; + + (Client as unknown as _Client)._registerCaptureHandler(handler); +} + +/** Test seam — un-register so the next call re-installs cleanly. */ +export function _resetOtelCaptureForTests(): void { + installed = false; + (Client as unknown as _Client)._registerCaptureHandler(null); +} + +function recordOnSpan( + span: NonNullable>, + event: ObservabilityEvent, + raw: { error?: unknown; message?: string; extra?: { tags?: Record } }, +): void { + const isException = (event.exception?.length ?? 0) > 0; + const attrs: Attributes = { + 'smoo.event_id': event.eventId, + ...(event.environment ? { 'deployment.environment.name': event.environment } : {}), + ...(event.release ? { 'service.version': event.release } : {}), + ...(event.level ? { 'smoo.level': event.level } : {}), + }; + if (event.user?.id) attrs['enduser.id'] = event.user.id; + if (event.user?.orgId) attrs['enduser.org_id'] = event.user.orgId; + if (event.user?.sessionId) attrs['enduser.session_id'] = event.user.sessionId; + if (event.tags) { + for (const [k, v] of Object.entries(event.tags)) { + attrs[`smoo.tag.${k}`] = v; + } + } + + if (isException) { + const err = raw.error; + if (err instanceof Error) { + span.recordException(err); + } else { + span.recordException(new Error(typeof err === 'string' ? err : 'non-Error captured')); + } + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err instanceof Error ? err.message : event.exception?.[0]?.value, + }); + } else if (event.message) { + // captureMessage path — emit as a span event with the message; status + // stays UNSET unless the level is 'error' / 'fatal'. + span.addEvent('smoo.message', { ...attrs, 'smoo.message': event.message }); + if (event.level === 'error' || event.level === 'fatal') { + span.setStatus({ code: SpanStatusCode.ERROR, message: event.message }); + } + } + if (Object.keys(attrs).length > 0) span.setAttributes(attrs); +} diff --git a/packages/core/src/node/transport.ts b/packages/core/src/node/transport.ts deleted file mode 100644 index 16ced04..0000000 --- a/packages/core/src/node/transport.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Node-flavored Transport adapter. Mirrors `browser/transport.ts`: - * - No `sendBeacon` (Node has no equivalent). - * - No DOM lifecycle hooks — process-level SIGTERM/SIGINT flushing is wired - * by `registerNodeGlobalHandlers({ flush })` instead, so it's idempotent - * and doesn't fight the host app's signal handlers. - * - * Returns the underlying Transport instance so callers (e.g. node/index.ts) - * can wire `transport.flush()` into the global-handlers lifecycle. - */ - -import { Transport } from '../transport'; -import type { ClientOptions } from '../types'; - -export function makeNodeTransport(opts: ClientOptions): Transport { - const adapter = { - // Node has no Beacon API; force fetch-with-keepalive path on flush. - canBeacon: false, - // No bindLifecycle — process signals are handled in global-handlers - // so we don't double-register on imports of the transport alone. - }; - return new Transport( - { - dsn: opts.dsn, - flushIntervalMs: opts.flushIntervalMs, - maxBatchSize: opts.maxBatchSize, - maxQueueSize: opts.maxQueueSize, - }, - adapter, - ); -} diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index a62c40f..3916677 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -13,4 +13,5 @@ export default defineConfig({ splitting: false, treeshake: true, target: 'es2022', + external: ['@opentelemetry/api'], }); diff --git a/packages/otel/src/__tests__/bridge-to-client.test.ts b/packages/otel/src/__tests__/bridge-to-client.test.ts deleted file mode 100644 index 420e2ff..0000000 --- a/packages/otel/src/__tests__/bridge-to-client.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { context, ROOT_CONTEXT, SpanStatusCode, trace } from '@opentelemetry/api'; -import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { Client } from '@smooai/observability'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { _resetBridgeForTests, bridgeClientToOtel, readOtelCorrelation } from '../bridge-to-client'; - -const exporter = new InMemorySpanExporter(); -const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], -}); -trace.setGlobalTracerProvider(provider); -// Without a registered context manager, OTel's default no-op manager makes -// startActiveSpan a no-op for `getActiveSpan()`. AsyncHooksContextManager is -// the production-equivalent here. -const contextManager = new AsyncHooksContextManager(); -contextManager.enable(); -context.setGlobalContextManager(contextManager); - -describe('bridgeClientToOtel', () => { - beforeEach(() => { - Client.init({ dsn: 'https://ingest.example/wh/o/t' }); - _resetBridgeForTests(); - exporter.reset(); - }); - - afterEach(() => { - _resetBridgeForTests(); - }); - - it('records the exception on the active span and marks status ERROR', () => { - bridgeClientToOtel(); - const tracer = trace.getTracer('test'); - tracer.startActiveSpan('handler', (span) => { - try { - Client.captureException(new Error('boom')); - } finally { - span.end(); - } - }); - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(1); - const handlerSpan = spans[0]!; - expect(handlerSpan.status.code).toBe(SpanStatusCode.ERROR); - expect(handlerSpan.events.map((e) => e.name)).toContain('exception'); - }); - - it('mints a synthetic span when no span is active', () => { - bridgeClientToOtel(); - // Force into ROOT_CONTEXT so no active span is present. - context.with(ROOT_CONTEXT, () => { - Client.captureException(new Error('no-context boom')); - }); - const spans = exporter.getFinishedSpans(); - expect(spans).toHaveLength(1); - expect(spans[0]!.name).toBe('observability.captureException'); - expect(spans[0]!.status.code).toBe(SpanStatusCode.ERROR); - }); - - it('propagates Smoo event id onto the span as an attribute', () => { - bridgeClientToOtel(); - const tracer = trace.getTracer('test'); - let eventId: string | undefined; - tracer.startActiveSpan('handler', (span) => { - eventId = Client.captureException(new Error('x')); - span.end(); - }); - const span = exporter.getFinishedSpans()[0]!; - expect(span.attributes['smoo.event_id']).toBe(eventId); - }); - - it('is idempotent — installing twice does not double-wrap', () => { - bridgeClientToOtel(); - bridgeClientToOtel(); - const tracer = trace.getTracer('test'); - tracer.startActiveSpan('handler', (span) => { - Client.captureException(new Error('once')); - span.end(); - }); - const span = exporter.getFinishedSpans()[0]!; - // Two installs would record the exception twice. - const exceptionEvents = span.events.filter((e) => e.name === 'exception'); - expect(exceptionEvents).toHaveLength(1); - }); - - it('readOtelCorrelation returns active trace/span ids', () => { - const tracer = trace.getTracer('test'); - let traceId: string | undefined; - let spanId: string | undefined; - tracer.startActiveSpan('outer', (span) => { - const corr = readOtelCorrelation(); - traceId = corr.traceId; - spanId = corr.spanId; - span.end(); - }); - expect(traceId).toMatch(/^[0-9a-f]{32}$/); - expect(spanId).toMatch(/^[0-9a-f]{16}$/); - }); - - it('readOtelCorrelation returns empty when no span active', () => { - context.with(ROOT_CONTEXT, () => { - const corr = readOtelCorrelation(); - expect(corr).toEqual({}); - }); - }); - - it('bridge does not throw if Client.captureException throws internally', () => { - bridgeClientToOtel(); - const orig = Client.captureException; - (Client as unknown as { captureException: (...args: unknown[]) => unknown }).captureException = () => { - throw new Error('transport down'); - }; - const tracer = trace.getTracer('test'); - // Wrap restoration so the rest of the suite still works. - try { - tracer.startActiveSpan('handler', (span) => { - expect(() => Client.captureException(new Error('outer'))).toThrow('transport down'); - span.end(); - }); - } finally { - (Client as unknown as { captureException: typeof orig }).captureException = orig; - } - }); -}); diff --git a/packages/otel/src/bridge-to-client.ts b/packages/otel/src/bridge-to-client.ts deleted file mode 100644 index 6b727a5..0000000 --- a/packages/otel/src/bridge-to-client.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Bridge `@smooai/observability` core Client → OpenTelemetry. - * - * Effect of `bridgeClientToOtel()`: - * 1. Every captured exception becomes a recorded exception on the active - * OTel span and sets the span status to ERROR. If no span is active, - * a synthetic span is created so the error still surfaces in the trace. - * 2. Every Smoo event picks up `traceId` + `spanId` from the active OTel - * context so dashboards correlate one-click between traces and errors. - * 3. Tag/user updates propagate to span attributes. - * - * The bridge wraps `Client.captureException` / `Client.setUser` / `Client.setTag` - * rather than re-implementing them, so the existing Smoo wire format keeps - * working — OTel becomes an additional output, not a replacement. - * - * Idempotent — installing twice is a no-op. - * - * After Phase 2 (ingest swap) and Phase 3 (metrics SDK rebuild on OTel meters), - * this bridge becomes the primary capture path and the legacy transport - * becomes optional. - */ - -import { type Attributes, SpanStatusCode, trace } from '@opentelemetry/api'; -import { Client } from '@smooai/observability'; -import { readOtelCorrelation } from './read-otel-context'; - -let installed = false; -// Save the un-wrapped originals so `_resetBridgeForTests` can fully restore -// them. Without this, calling reset → bridgeClientToOtel again wraps the -// already-wrapped functions, causing double captures in subsequent tests. -interface OriginalRefs { - capture: ClientLike['captureException']; - setUser: ClientLike['setUser']; - setTag: ClientLike['setTag']; -} -let originalRefs: OriginalRefs | null = null; - -export interface BridgeOptions { - /** - * Tracer name used when we have to mint a synthetic span (no active span - * at capture time). Default: 'smooai.observability'. - */ - tracerName?: string; -} - -interface ClientLike { - _isInitialized: () => boolean; - captureException: (error: unknown, extra?: { tags?: Record }) => string | undefined; - setUser?: (user: { id?: string; orgId?: string; sessionId?: string } | undefined) => void; - setTag?: (key: string, value: string) => void; -} - -export function bridgeClientToOtel(options: BridgeOptions = {}): void { - if (installed) return; - installed = true; - - const tracer = trace.getTracer(options.tracerName ?? 'smooai.observability'); - const client = Client as unknown as ClientLike; - originalRefs = { - capture: client.captureException.bind(Client), - setUser: client.setUser?.bind(Client), - setTag: client.setTag?.bind(Client), - }; - const originalCapture = originalRefs.capture; - const originalSetUser = originalRefs.setUser; - const originalSetTag = originalRefs.setTag; - - client.captureException = (error, extra) => { - const eventId = originalCapture(error, extra); - try { - const active = trace.getActiveSpan(); - if (active) { - recordOnSpan(active, error, eventId, extra?.tags); - } else { - // No active span — mint a one-off so the trace still has signal. - const span = tracer.startSpan('observability.captureException'); - try { - recordOnSpan(span, error, eventId, extra?.tags); - } finally { - span.end(); - } - } - } catch { - /* swallow — bridge must never throw into user code */ - } - return eventId; - }; - - if (originalSetUser) { - client.setUser = (user) => { - originalSetUser(user); - try { - const span = trace.getActiveSpan(); - if (span && user) { - if (user.id) span.setAttribute('enduser.id', user.id); - if (user.orgId) span.setAttribute('enduser.org_id', user.orgId); - if (user.sessionId) span.setAttribute('enduser.session_id', user.sessionId); - } - } catch { - /* swallow */ - } - }; - } - - if (originalSetTag) { - client.setTag = (key, value) => { - originalSetTag(key, value); - try { - trace.getActiveSpan()?.setAttribute(`smoo.tag.${key}`, value); - } catch { - /* swallow */ - } - }; - } -} - -function recordOnSpan( - span: ReturnType extends infer S ? Exclude : never, - error: unknown, - eventId: string | undefined, - tags: Record | undefined, -): void { - if (!span) return; - if (error instanceof Error) { - span.recordException(error); - } else { - span.recordException(new Error(typeof error === 'string' ? error : 'non-Error captured')); - } - const attrs: Attributes = {}; - if (eventId) attrs['smoo.event_id'] = eventId; - if (tags) { - for (const [k, v] of Object.entries(tags)) { - attrs[`smoo.tag.${k}`] = v; - } - } - if (Object.keys(attrs).length > 0) span.setAttributes(attrs); - span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error) }); -} - -/** - * Test seam — restore the un-wrapped Client methods so re-bridging in test - * runs doesn't compound wrappers. No-op outside tests. - */ -export function _resetBridgeForTests(): void { - if (originalRefs) { - const client = Client as unknown as ClientLike; - client.captureException = originalRefs.capture; - if (originalRefs.setUser) client.setUser = originalRefs.setUser; - if (originalRefs.setTag) client.setTag = originalRefs.setTag; - originalRefs = null; - } - installed = false; -} - -// Re-export so consumers have one import path for everything. -export { readOtelCorrelation } from './read-otel-context'; diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts index e820482..eaec710 100644 --- a/packages/otel/src/index.ts +++ b/packages/otel/src/index.ts @@ -4,18 +4,20 @@ * Public surface: * - `setupOtelSdk(options)` — Lambda / Node bootstrap. Sets up NodeSDK with * OTLP/HTTP trace export + standard auto-instrumentations. Idempotent. - * - `bridgeClientToOtel()` — wraps the core Client so captureException also - * records on the active OTel span, and setUser/setTag flow through to - * span attributes. Use this to get one-click correlation between traces - * and Smoo error groups. * - `readOtelCorrelation()` — read-only view of the active span's traceId / - * spanId / sampled flag, for embedding into other event shapes. + * spanId / sampled flag, useful for embedding into other event shapes + * (e.g. logger formats, audit-log envelopes). + * + * **The bridge pattern (`bridgeClientToOtel`) is gone** — the Smoo core + * Client now emits to OpenTelemetry natively on Node (see + * `@smooai/observability/node`'s `registerOtelCapture`). There's nothing + * to bridge: span events ARE the capture path. * * Usage (in a Lambda handler entry): * * ```ts - * import { setupOtelSdk, bridgeClientToOtel } from '@smooai/observability-otel'; - * import { Client } from '@smooai/observability'; + * import { setupOtelSdk } from '@smooai/observability-otel'; + * import { Client } from '@smooai/observability/node'; * * const otel = setupOtelSdk({ * serviceName: 'smoo-backend', @@ -24,13 +26,12 @@ * }); * * Client.init({ dsn: 'https://api.smoo.ai/webhooks/observability/...' }); - * bridgeClientToOtel(); + * // captureException now records on OTel spans automatically — no bridge call. * * process.on('beforeExit', () => otel.flush()); * ``` */ -export { bridgeClientToOtel, readOtelCorrelation, _resetBridgeForTests } from './bridge-to-client'; -export type { BridgeOptions } from './bridge-to-client'; +export { readOtelCorrelation } from './read-otel-context'; export { setupOtelSdk, _resetOtelSdkForTests } from './setup-otel-sdk'; export type { OtelSdkHandle, SetupOtelOptions } from './setup-otel-sdk'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 013d5d6..3c319f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,17 @@ importers: version: 3.2.4(@types/node@22.19.19) packages/core: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 devDependencies: + '@opentelemetry/context-async-hooks': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.1) '@types/node': specifier: ^22 version: 22.19.19