Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/smoodev-1067d-otel-native-client.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
145 changes: 145 additions & 0 deletions packages/core/src/__tests__/otel-capture.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
57 changes: 46 additions & 11 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } }) => 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<void>) | 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 {
Expand All @@ -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);
}
Expand All @@ -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 */
Expand All @@ -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;
Expand Down
59 changes: 25 additions & 34 deletions packages/core/src/node/index.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
const t = setTimeout(resolve, timeoutMs);
t.unref?.();
}),
]);
},
});
registerNodeGlobalHandlers({ exitOnUncaught: false });
}
};
Loading
Loading