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
8 changes: 7 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@smooai/observability",
"version": "0.5.0",
"version": "0.6.0",
"description": "Smoo AI Observability SDK — OTel-first error capture, traces, metrics, and React/Next.js integrations in a single package with subpath exports",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -35,6 +35,10 @@
"types": "./dist/otel.d.mts",
"import": "./dist/otel.mjs"
},
"./metrics": {
"types": "./dist/metrics.d.mts",
"import": "./dist/metrics.mjs"
},
"./react": {
"types": "./dist/react.d.mts",
"import": "./dist/react.mjs"
Expand Down Expand Up @@ -62,8 +66,10 @@
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/resources": "^1.30.0",
"@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/semantic-conventions": "^1.30.0"
},
Expand Down
153 changes: 153 additions & 0 deletions packages/core/src/metrics/__tests__/metrics-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { metrics } from '@opentelemetry/api';
import { AggregationTemporality, InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { _resetMetricsInstrumentCacheForTests, getMetricsClient } from '../index';

/**
* Drive the metrics module with an in-memory exporter so we can inspect
* exactly what got recorded without standing up an OTel collector.
*/
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 50 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);

async function collect(): Promise<ReturnType<InMemoryMetricExporter['getMetrics']>> {
await provider.forceFlush();
return exporter.getMetrics();
}

describe('MetricsClient', () => {
beforeEach(() => {
_resetMetricsInstrumentCacheForTests();
exporter.reset();
});

afterEach(() => {
exporter.reset();
});

it('counter increments a Counter instrument with attributes', async () => {
const m = getMetricsClient('test-service');
m.counter('agent.turn.completed', 1, { channel: 'voice' });
m.counter('agent.turn.completed', 1, { channel: 'voice' });
m.counter('agent.turn.completed', 1, { channel: 'webchat' });

const recorded = await collect();
const allPoints = recorded.flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics));
const counter = allPoints.find((p) => p.descriptor.name === 'agent.turn.completed');
expect(counter).toBeDefined();
const sum = counter!.dataPoints.reduce((acc, dp) => acc + (dp.value as number), 0);
expect(sum).toBe(3);
});

it('histogram records observations', async () => {
const m = getMetricsClient('test-service');
m.histogram('agent.tokens.used', 120, { model: 'sonnet' });
m.histogram('agent.tokens.used', 230, { model: 'sonnet' });

const recorded = await collect();
const allPoints = recorded.flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics));
const hist = allPoints.find((p) => p.descriptor.name === 'agent.tokens.used');
expect(hist).toBeDefined();
expect(hist!.dataPoints.length).toBeGreaterThan(0);
});

it('timing emits a histogram with unit=ms', async () => {
const m = getMetricsClient('test-service');
m.timing('agent.ttft.ms', 312, { model: 'sonnet' });

const recorded = await collect();
const allPoints = recorded.flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics));
const hist = allPoints.find((p) => p.descriptor.name === 'agent.ttft.ms');
expect(hist).toBeDefined();
expect(hist!.descriptor.unit).toBe('ms');
});

it('startTimer records elapsed ms when the stop callback is invoked', async () => {
const m = getMetricsClient('test-service');
const stop = m.startTimer('agent.tool.latency.ms', { tool: 'knowledge_search' });
await new Promise((r) => setTimeout(r, 30));
stop();

const recorded = await collect();
const allPoints = recorded.flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics));
const hist = allPoints.find((p) => p.descriptor.name === 'agent.tool.latency.ms');
expect(hist).toBeDefined();
// We can't easily inspect histogram bucket values; presence is enough.
expect(hist!.dataPoints.length).toBeGreaterThan(0);
});

it('withTiming tags the recording with status=success on resolve', async () => {
const m = getMetricsClient('test-service');
const result = await m.withTiming('agent.turn.duration.ms', async () => {
await new Promise((r) => setTimeout(r, 10));
return 42;
});
expect(result).toBe(42);

const recorded = await collect();
const allPoints = recorded.flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics));
const hist = allPoints.find((p) => p.descriptor.name === 'agent.turn.duration.ms');
expect(hist).toBeDefined();
const dp = hist!.dataPoints[0]!;
expect((dp.attributes as Record<string, unknown>).status).toBe('success');
});

it('withTiming tags the recording with status=error on throw and rethrows', async () => {
const m = getMetricsClient('test-service');
await expect(
m.withTiming('agent.turn.duration.ms', async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');

const recorded = await collect();
const allPoints = recorded.flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics));
const hist = allPoints.find((p) => p.descriptor.name === 'agent.turn.duration.ms');
expect(hist).toBeDefined();
const errorPoint = hist!.dataPoints.find((dp) => (dp.attributes as Record<string, unknown>).status === 'error');
expect(errorPoint).toBeDefined();
});

it('reuses the same instrument across calls (no leaks)', async () => {
const m = getMetricsClient('test-service');
for (let i = 0; i < 100; i++) m.counter('agent.spin');

const recorded = await collect();
const allPoints = recorded.flatMap((r) => r.scopeMetrics.flatMap((s) => s.metrics));
const counter = allPoints.find((p) => p.descriptor.name === 'agent.spin');
expect(counter).toBeDefined();
const sum = counter!.dataPoints.reduce((acc, dp) => acc + (dp.value as number), 0);
expect(sum).toBe(100);
});

it('all methods swallow internal errors — observability never throws into user code', () => {
// Force the global MeterProvider to a thrower to simulate a broken setup.
const originalProvider = metrics.getMeterProvider();
metrics.setGlobalMeterProvider({
getMeter() {
return {
createCounter() {
throw new Error('synthetic');
},
createHistogram() {
throw new Error('synthetic');
},
} as never;
},
} as never);
try {
_resetMetricsInstrumentCacheForTests();
const m = getMetricsClient('broken');
expect(() => m.counter('x', 1)).not.toThrow();
expect(() => m.histogram('y', 1)).not.toThrow();
expect(() => m.timing('z', 1)).not.toThrow();
const stop = m.startTimer('q');
expect(() => stop()).not.toThrow();
} finally {
metrics.setGlobalMeterProvider(originalProvider);
_resetMetricsInstrumentCacheForTests();
}
});
});
157 changes: 157 additions & 0 deletions packages/core/src/metrics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* @smooai/observability/metrics — OpenTelemetry Meter wrapper for Smoo
* application metrics.
*
* Thin Smoo-flavored API on top of `@opentelemetry/api`'s metrics surface.
* Same shape as the rest of `@smooai/observability` (`Client.captureException`,
* `setupOtelSdk`, etc.): a tiny ergonomic layer over OTel so consumers don't
* have to learn the OTel API just to emit counters.
*
* Usage in node services:
*
* ```ts
* import { setupOtelSdk } from '@smooai/observability/otel';
* import { getMetricsClient } from '@smooai/observability/metrics';
*
* setupOtelSdk({ serviceName: 'smooai-voice' }); // also wires metrics export
* const metrics = getMetricsClient('smooai-voice');
*
* metrics.counter('agent.turn.completed', 1, { channel: 'voice', tier: 'pro' });
* metrics.timing('agent.ttft.ms', 312, { model: 'sonnet' });
* const stop = metrics.startTimer('agent.tool.latency.ms', { tool: 'knowledge_search' });
* await doWork();
* stop();
* ```
*
* The same instrument name is reused across calls — instruments are cached
* by `(meterName, instrumentName)` so we don't leak Meter handles.
*/

import { type Attributes, type Counter, type Histogram, metrics as otelMetrics } from '@opentelemetry/api';

export interface MetricsClient {
/** Add to a monotonically-increasing counter. Most common shape. */
counter(name: string, value?: number, attrs?: Record<string, string>): void;
/**
* Record a histogram observation. Use this for distributions
* (latencies, sizes, etc.) — the backend will compute percentiles.
*/
histogram(name: string, value: number, attrs?: Record<string, string>): void;
/**
* Alias for `histogram` with `unit: 'ms'` baked in. Renders nicer in
* dashboards as a duration.
*/
timing(name: string, ms: number, attrs?: Record<string, string>): void;
/**
* Start a wall-clock timer. Call the returned function when the
* operation completes to record the elapsed milliseconds as a timing
* histogram. Use for code that doesn't fit a single async block.
*/
startTimer(name: string, attrs?: Record<string, string>): () => void;
/**
* Wrap an async function in a timing measurement. Records the elapsed
* ms on success or failure (with `status=success|error` attribute).
*/
withTiming<T>(name: string, fn: () => Promise<T>, attrs?: Record<string, string>): Promise<T>;
}

const counterCache = new Map<string, Counter>();
const histogramCache = new Map<string, Histogram>();

function getCounter(meterName: string, name: string): Counter {
const key = `${meterName}::${name}`;
let inst = counterCache.get(key);
if (!inst) {
inst = otelMetrics.getMeter(meterName).createCounter(name);
counterCache.set(key, inst);
}
return inst;
}

function getHistogram(meterName: string, name: string, unit?: string): Histogram {
const key = `${meterName}::${name}::${unit ?? ''}`;
let inst = histogramCache.get(key);
if (!inst) {
inst = otelMetrics.getMeter(meterName).createHistogram(name, { unit });
histogramCache.set(key, inst);
}
return inst;
}

function toAttributes(attrs?: Record<string, string>): Attributes | undefined {
if (!attrs) return undefined;
return attrs as Attributes;
}

/**
* Build a metrics client bound to a specific service-named meter. Cheap;
* call per service / module if you want logical grouping.
*
* Defaults to meter name `@smooai/observability` so any caller can `getMetricsClient()`
* with no args and still emit. Production callers pass their service name
* (e.g. `smooai-voice`, `smooai-backend`) so dashboards can filter by
* `instrumentation.scope.name`.
*/
export function getMetricsClient(meterName: string = '@smooai/observability'): MetricsClient {
return {
counter(name, value = 1, attrs) {
try {
getCounter(meterName, name).add(value, toAttributes(attrs));
} catch {
/* observability MUST NOT throw into user code */
}
},
histogram(name, value, attrs) {
try {
getHistogram(meterName, name).record(value, toAttributes(attrs));
} catch {
/* swallow */
}
},
timing(name, ms, attrs) {
try {
getHistogram(meterName, name, 'ms').record(ms, toAttributes(attrs));
} catch {
/* swallow */
}
},
startTimer(name, attrs) {
const start = Date.now();
return () => {
const ms = Date.now() - start;
try {
getHistogram(meterName, name, 'ms').record(ms, toAttributes(attrs));
} catch {
/* swallow */
}
};
},
async withTiming(name, fn, attrs) {
const start = Date.now();
try {
const result = await fn();
const ms = Date.now() - start;
try {
getHistogram(meterName, name, 'ms').record(ms, toAttributes({ ...attrs, status: 'success' }));
} catch {
/* swallow */
}
return result;
} catch (err) {
const ms = Date.now() - start;
try {
getHistogram(meterName, name, 'ms').record(ms, toAttributes({ ...attrs, status: 'error' }));
} catch {
/* swallow */
}
throw err;
}
},
};
}

/** Test seam — drop cached instruments so a fresh MeterProvider takes effect. */
export function _resetMetricsInstrumentCacheForTests(): void {
counterCache.clear();
histogramCache.clear();
}
Loading
Loading