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
18 changes: 18 additions & 0 deletions .changeset/smoodev-1067c-otel-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@smooai/observability-otel': minor
'@smooai/observability': patch
---

`@smooai/observability-otel` — OpenTelemetry foundation (SMOODEV-1067c Phase 1).

New package wraps `@opentelemetry/sdk-node` + `@opentelemetry/auto-instrumentations-node` + the OTLP/HTTP trace exporter, and bridges the core `Client` so every `captureException` records on the active OTel span with `SpanStatusCode.ERROR`. Works without `@smooai/logger` — pipes correlation IDs through `@opentelemetry/api`'s ambient context, so any logger / framework that integrates with OTel sees the same trace-id flowing through logs, traces, and Smoo error groups.

Public surface:

- `setupOtelSdk({ serviceName, otlpEndpoint, otlpHeaders, environment, release, instrumentationConfig })` — idempotent Lambda / Node bootstrap. Returns `{ sdk, flush, shutdown }`.
- `bridgeClientToOtel()` — wraps `Client.captureException` / `setUser` / `setTag` to also update OTel span attributes + status. Idempotent.
- `readOtelCorrelation()` — read-only view of the active span's `traceId` / `spanId` / sampled flag.

Also patches `@smooai/observability` core docs reference; no API change.

12 tests (bridge + setup), typecheck + build clean.
3 changes: 2 additions & 1 deletion packages/core/src/__tests__/node-global-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ describe('registerNodeGlobalHandlers', () => {
// process.emit is typed with strict overloads per signal name; cast through
// a loose-typed alias so tests can fire the events we just registered.
// Must keep `this` bound to process or EventEmitter lookups crash.
const emit = (event: string, ...args: unknown[]): boolean => (process.emit as unknown as (event: string, ...args: unknown[]) => boolean).call(process, event, ...args);
const emit = (event: string, ...args: unknown[]): boolean =>
(process.emit as unknown as (event: string, ...args: unknown[]) => boolean).call(process, event, ...args);

it('captures uncaughtException via Client.captureException', () => {
registerNodeGlobalHandlers();
Expand Down
45 changes: 45 additions & 0 deletions packages/otel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# @smooai/observability-otel

OpenTelemetry foundation for [`@smooai/observability`](../core).

Sets up the OTel NodeSDK with OTLP/HTTP trace export and the standard auto-instrumentations bundle, then bridges the core `Client` so every `captureException` records on the active OTel span. The result: one-click correlation between traces, error groups, and logs — regardless of which logger you use.

## What you get

- **`setupOtelSdk(options)`** — Lambda / Node bootstrap. NodeSDK + OTLP/HTTP trace exporter + auto-instrumentations for HTTP / fetch / Postgres / Redis / etc. Idempotent.
- **`bridgeClientToOtel()`** — wraps `Client.captureException`, `Client.setUser`, `Client.setTag` so they also update the active OTel span. Exceptions become span events with `SpanStatusCode.ERROR`. If no span is active at capture time, a synthetic one is minted.
- **`readOtelCorrelation()`** — read the active span's `traceId` / `spanId` / sampled flag for embedding into other event shapes.

## Quick start

```ts
import { setupOtelSdk, bridgeClientToOtel } from '@smooai/observability-otel';
import { Client } from '@smooai/observability';

const otel = setupOtelSdk({
serviceName: 'smoo-backend',
environment: process.env.SST_STAGE,
release: process.env.LAMBDA_FUNCTION_VERSION,
});

Client.init({ dsn: 'https://api.smoo.ai/webhooks/observability/ORG/TOKEN' });
bridgeClientToOtel();

process.on('beforeExit', () => otel.flush());
```

## Without `@smooai/logger`

This package depends on `@opentelemetry/api`, not on `@smooai/logger`. If you use winston, pino, bunyan, or console — pipe `readOtelCorrelation()` into your own log format and you get the same trace-id on logs / traces / errors.

```ts
import { readOtelCorrelation } from '@smooai/observability-otel';

logger.info('hello', { ...readOtelCorrelation() });
```

## Where this sits in the SDK

This is **Phase 1** of the OTel migration tracked under SMOODEV-1067c. Phase 2 swaps the ingest backend to accept OTLP/HTTP alongside the existing Smoo-native wire format. Phase 3 rebuilds the metrics SDK on OTel meters. Phase 4 turns `@smooai/logger`'s context into OTel baggage.

Until Phase 2 lands, `bridgeClientToOtel()` is additive — Smoo's existing transport still ships events to the backend; OTel becomes a parallel output for tracers.
57 changes: 57 additions & 0 deletions packages/otel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@smooai/observability-otel",
"version": "0.1.0",
"description": "OpenTelemetry foundation for @smooai/observability — Node SDK setup, auto-instrumentations, OTLP export, and bridge to the core Client",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/SmooAI/observability.git",
"directory": "packages/otel"
},
"homepage": "https://github.com/SmooAI/observability/tree/main/packages/otel",
"bugs": {
"url": "https://github.com/SmooAI/observability/issues"
},
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsup",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit",
"lint": "echo \"(lint stub — biome/eslint TBD)\""
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/resources": "^1.28.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"@smooai/observability": "workspace:*"
},
"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",
"vitest": "^3.1.1"
},
"publishConfig": {
"access": "public"
},
"packageManager": "pnpm@10.6.1+sha512.40ee09af407fa9fbb5fbfb8e1cb40fbb74c0af0c3e10e9224d7b53c7658528615b2c92450e74cfad91e3a2dcafe3ce4050d80bda71d757756d2ce2b66213e9a3"
}
124 changes: 124 additions & 0 deletions packages/otel/src/__tests__/bridge-to-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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;
}
});
});
42 changes: 42 additions & 0 deletions packages/otel/src/__tests__/setup-otel-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest';
import { _resetOtelSdkForTests, setupOtelSdk } from '../setup-otel-sdk';

describe('setupOtelSdk', () => {
afterEach(() => {
_resetOtelSdkForTests();
});

it('returns a handle with sdk, flush, shutdown', () => {
const handle = setupOtelSdk({ serviceName: 'test', skipStart: true });
expect(handle.sdk).toBeDefined();
expect(typeof handle.flush).toBe('function');
expect(typeof handle.shutdown).toBe('function');
});

it('is idempotent — second call returns the same handle', () => {
const a = setupOtelSdk({ serviceName: 'test', skipStart: true });
const b = setupOtelSdk({ serviceName: 'test', skipStart: true });
expect(a).toBe(b);
});

it('shutdown clears the install guard so a new init returns a new handle', async () => {
const a = setupOtelSdk({ serviceName: 'test', skipStart: true });
await a.shutdown();
const b = setupOtelSdk({ serviceName: 'test', skipStart: true });
expect(b).not.toBe(a);
});

it('flush resolves within the timeout even when exporter is silent', async () => {
const handle = setupOtelSdk({ serviceName: 'test', skipStart: true });
const start = Date.now();
await handle.flush(50);
const elapsed = Date.now() - start;
// Allow generous slack for CI scheduler jitter.
expect(elapsed).toBeLessThan(500);
});

it('accepts disableAutoInstrumentations without crashing', () => {
const handle = setupOtelSdk({ serviceName: 'test', skipStart: true, disableAutoInstrumentations: true });
expect(handle.sdk).toBeDefined();
});
});
Loading
Loading