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
12 changes: 12 additions & 0 deletions .changeset/session-scoped-sampling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@smooai/observability': minor
---

SMOODEV-2698 (ADR-097 W1+W2): session-scoped browser log sampling, config-served telemetry settings, and the cross-language parity corpus.

- `sampleDecision(id, ratio)` — deterministic FNV-1a 32-bit over the UTF-8 bytes of the session/trace id, so the decision is stable for a page's lifetime and reproducible byte-identically in the Rust/Python/Go/.NET SDKs. Ratio 0.0/1.0 are exact.
- `shouldEmitLog(...)` — one decision point: kill switch → minimum level → warnings/errors always 100% → trace decision inherited where a trace exists → otherwise the session decision. Sampling is per session, never per line, so any trace you can open has 100% of its log lines.
- `loadTelemetrySettings(provider)` / `resolveTelemetrySettings(raw)` — `@smooai/config` public-tier telemetry settings read through an injectable provider seam (the SDK never imports a config client, so it stays usable with no network). Unreachable, malformed, or out-of-range values fall back to the compiled-in ADR-010 defaults, never to "sample everything out".
- `parseTraceparent` / `formatTraceparent` — the first real W3C trace-context implementation in this SDK; strict, rejects all-zero ids.
- `normalizeLevel` — canonical UPPERCASE levels, because ADR-096's error-rate query is case-sensitive.
- `parity/sampling-corpus.json` — 170 committed golden vectors every language SDK asserts against in its own CI lane.
5 changes: 5 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
with:
filters: |
ts:
- 'parity/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
Expand All @@ -39,15 +40,19 @@ jobs:
- '.changeset/**'
- '.github/workflows/pr-checks.yml'
rust:
- 'parity/**'
- 'rust/**'
- '.github/workflows/pr-checks.yml'
go:
- 'parity/**'
- 'go/**'
- '.github/workflows/pr-checks.yml'
python:
- 'parity/**'
- 'python/**'
- '.github/workflows/pr-checks.yml'
dotnet:
- 'parity/**'
- 'dotnet/**'
- '.github/workflows/pr-checks.yml'

Expand Down
24 changes: 24 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ Client.addBreadcrumb('fetch', 'POST /api/checkout 502', { method: 'POST', status
Client.setUser({ id: 'user_abc', orgId: 'org_xyz', sessionId: 'sess_123' });
```

### Sampling and telemetry settings (ADR-097)

Browser logs are sampled **by session, never by line** — the decision is made
once per session (or inherited from the trace, where one exists) and applies to
every line under it, so any trace you can open has 100% of its log lines.
Warnings and errors are always kept. Server-side logs are not sampled.

```ts
import { loadTelemetrySettings, sampleDecision, shouldEmitLog } from '@smooai/observability';

// Settings come from @smooai/config public-tier keys. The SDK never imports
// the config client — you inject a provider, so it stays usable offline.
const settings = await loadTelemetrySettings(() => publicConfig.getAll());
// ...unreachable / malformed / out-of-range → compiled-in ADR-010 defaults.
// Never "sample everything out".

shouldEmitLog({ level: 'info', sessionId, ...settings, minimumLevel: settings.minimumLogLevel, logSamplingRatio: settings.browserLogSamplingRatio });
```

`sampleDecision(id, ratio)` is FNV-1a 32-bit over the UTF-8 bytes of the id —
deterministic, stable for a page's lifetime, and reproduced byte-identically by
the Rust / Python / Go / .NET SDKs against
[`parity/sampling-corpus.json`](../../parity/README.md).

## What it does NOT do

- Does not capture `console.log` / `console.info` / `console.warn`
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/__tests__/parity-corpus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* ADR-097 §4 — the TypeScript lane of the parity corpus.
*
* Every SDK (TS, Rust, Python, Go, .NET) asserts against the same
* `parity/sampling-corpus.json` in its own CI. A language that cannot reproduce
* a vector fails its build. Documentation claiming parity is not evidence.
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { fnv1a32, normalizeLevel, sampleDecision, shouldEmitLog, type CanonicalLevel } from '../sampling';
import { formatTraceparent, parseTraceparent } from '../traceparent';
import { resolveTelemetrySettings } from '../telemetry-settings';

const CORPUS_PATH = join(dirname(fileURLToPath(import.meta.url)), '../../../../parity/sampling-corpus.json');

interface Corpus {
version: number;
sampleDecision: { id: string; ratio: number; hash: number; expected: boolean }[];
sampleDecisionNearThreshold: { id: string; ratio: number; hash: number; position: number; expected: boolean }[];
sampleDecisionNonFiniteRatio: { id: string; ratio: string; expected: boolean }[];
levelNormalization: { input: string; expected: string }[];
traceparentParse: { input: string; expected: { traceId: string; spanId: string; flags: number; sampled: boolean } | null }[];
traceparentFormat: { input: { traceId: string; spanId: string; flags?: number; sampled?: boolean }; expected: string | null }[];
settingsResolution: { input: unknown; expected: Record<string, unknown> }[];
shouldEmitLog: {
input: { level: string; sessionId: string; traceSampled?: boolean; enabled: boolean; minimumLevel: string; logSamplingRatio: number };
expected: boolean;
}[];
}

const corpus: Corpus = JSON.parse(readFileSync(CORPUS_PATH, 'utf8'));

const NON_FINITE: Record<string, number> = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, '-Infinity': Number.NEGATIVE_INFINITY };

describe('parity corpus', () => {
it('is the expected version and is not empty', () => {
expect(corpus.version).toBe(1);
expect(corpus.sampleDecision.length).toBeGreaterThan(50);
});

it.each(corpus.sampleDecision)('sampleDecision($id, $ratio) === $expected', (v) => {
expect(fnv1a32(v.id)).toBe(v.hash);
expect(sampleDecision(v.id, v.ratio)).toBe(v.expected);
});

it.each(corpus.sampleDecisionNearThreshold)('near-threshold sampleDecision($id, $ratio) === $expected', (v) => {
expect(fnv1a32(v.id)).toBe(v.hash);
expect(fnv1a32(v.id) / 2 ** 32).toBeCloseTo(v.position, 12);
expect(sampleDecision(v.id, v.ratio)).toBe(v.expected);
});

it.each(corpus.sampleDecisionNonFiniteRatio)('non-finite ratio $ratio fails open', (v) => {
expect(sampleDecision(v.id, NON_FINITE[v.ratio]!)).toBe(v.expected);
});

it.each(corpus.levelNormalization)('normalizeLevel($input) === $expected', (v) => {
expect(normalizeLevel(v.input)).toBe(v.expected);
});

it.each(corpus.traceparentParse)('parseTraceparent($input)', (v) => {
expect(parseTraceparent(v.input)).toEqual(v.expected);
});

it.each(corpus.traceparentFormat)('formatTraceparent -> $expected', (v) => {
expect(formatTraceparent(v.input)).toBe(v.expected);
});

it.each(corpus.settingsResolution)('resolveTelemetrySettings #%#', (v) => {
expect(resolveTelemetrySettings(v.input)).toEqual(v.expected);
});

it.each(corpus.shouldEmitLog)('shouldEmitLog #%#', (v) => {
expect(shouldEmitLog({ ...v.input, minimumLevel: v.input.minimumLevel as CanonicalLevel })).toBe(v.expected);
});
});
179 changes: 179 additions & 0 deletions packages/core/src/__tests__/sampling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* ADR-097 W1/W2 behaviour tests — the properties the corpus cannot express
* (published hash vectors, provider failure modes, session stability).
*/
import { describe, expect, it, vi } from 'vitest';
import { createDropCounter, fnv1a32, meetsMinimumLevel, sampleDecision, shouldEmitLog } from '../sampling';
import { DEFAULT_TELEMETRY_SETTINGS, loadTelemetrySettings, TELEMETRY_SETTING_KEYS } from '../telemetry-settings';
import { formatTraceparent, parseTraceparent } from '../traceparent';

describe('fnv1a32', () => {
// Published FNV-1a-32 test vectors. These are the anchor: if the corpus and
// the implementation are both wrong in the same way, these still catch it.
it.each([
['', 0x811c9dc5],
['a', 0xe40c292c],
['foobar', 0xbf9cf968],
])('hashes %j to the published vector', (input, expected) => {
expect(fnv1a32(input)).toBe(expected);
});

it('hashes UTF-8 bytes, not UTF-16 code units', () => {
// '🎉' is one code point, two UTF-16 units, four UTF-8 bytes (f0 9f 8e 89).
// Fold those four bytes by hand and require the implementation to agree —
// a port that walks UTF-16 units or code points gets a different answer.
let expected = 0x811c9dc5;
for (const b of [0xf0, 0x9f, 0x8e, 0x89]) expected = Math.imul(expected ^ b, 0x01000193) >>> 0;
expect(fnv1a32('🎉')).toBe(expected);
});

it('stays inside unsigned 32-bit for long inputs', () => {
const h = fnv1a32('x'.repeat(10_000));
expect(Number.isInteger(h)).toBe(true);
expect(h).toBeGreaterThanOrEqual(0);
expect(h).toBeLessThan(2 ** 32);
});
});

describe('sampleDecision', () => {
it('is exact at the boundaries for every id', () => {
for (let i = 0; i < 1000; i++) {
expect(sampleDecision(`session-${i}`, 1)).toBe(true);
expect(sampleDecision(`session-${i}`, 0)).toBe(false);
}
});

it('is stable across repeated calls (a page must not flip mid-session)', () => {
const first = sampleDecision('session-abc', 0.37);
for (let i = 0; i < 100; i++) expect(sampleDecision('session-abc', 0.37)).toBe(first);
});

it('is monotonic in ratio', () => {
for (const id of ['a', 'b', 'session-xyz', '🎉']) {
let seenIn = false;
for (const ratio of [0, 0.1, 0.2, 0.4, 0.6, 0.8, 1]) {
const decision = sampleDecision(id, ratio);
if (seenIn) expect(decision).toBe(true);
seenIn ||= decision;
}
}
});

it('lands near the requested ratio over a population', () => {
const n = 20_000;
let kept = 0;
for (let i = 0; i < n; i++) if (sampleDecision(`session-${i}`, 0.25)) kept++;
expect(kept / n).toBeGreaterThan(0.235);
expect(kept / n).toBeLessThan(0.265);
});

it('fails open on non-finite ratios rather than going silently dark', () => {
expect(sampleDecision('s', Number.NaN)).toBe(true);
expect(sampleDecision('s', Number.POSITIVE_INFINITY)).toBe(true);
});
});

describe('shouldEmitLog', () => {
const base = { enabled: true, minimumLevel: 'INFO' as const, logSamplingRatio: 0, sessionId: 'session-out' };

it('never drops a warning or error, even at ratio 0', () => {
for (const level of ['warn', 'warning', 'error', 'fatal', 'critical']) {
expect(shouldEmitLog({ ...base, level })).toBe(true);
}
});

it('honours the kill switch above everything', () => {
expect(shouldEmitLog({ ...base, enabled: false, level: 'fatal' })).toBe(false);
});

it('inherits the trace decision over the session decision, both ways', () => {
expect(shouldEmitLog({ ...base, level: 'info', logSamplingRatio: 0, traceSampled: true })).toBe(true);
expect(shouldEmitLog({ ...base, level: 'info', logSamplingRatio: 1, traceSampled: false })).toBe(false);
});

it('keeps ALL lines of a sampled-in session and NONE of a sampled-out one', () => {
// The ADR's acceptance test: any trace you can open has 100% of its lines.
for (const sessionId of ['session-a', 'session-b', 'session-out', 'session-zzz']) {
const decisions = Array.from({ length: 50 }, (_, i) =>
shouldEmitLog({ ...base, sessionId, level: 'info', logSamplingRatio: 0.5, minimumLevel: 'INFO' }),
);
expect(new Set(decisions).size).toBe(1);
}
});

it('applies the minimum level before the always-on rule', () => {
expect(shouldEmitLog({ ...base, level: 'error', minimumLevel: 'FATAL' })).toBe(false);
expect(shouldEmitLog({ ...base, level: 'debug', minimumLevel: 'INFO' })).toBe(false);
expect(shouldEmitLog({ ...base, level: 'debug', minimumLevel: 'DEBUG', logSamplingRatio: 1 })).toBe(true);
});
});

describe('meetsMinimumLevel', () => {
it('orders levels', () => {
expect(meetsMinimumLevel('ERROR', 'WARN')).toBe(true);
expect(meetsMinimumLevel('WARN', 'WARN')).toBe(true);
expect(meetsMinimumLevel('INFO', 'WARN')).toBe(false);
});
});

describe('createDropCounter', () => {
it('tallies and drains — sampled-out volume stays observable', () => {
const c = createDropCounter();
expect(c.drain()).toEqual({});
c.record('INFO');
c.record('INFO');
c.record('DEBUG');
expect(c.drain()).toEqual({ INFO: 2, DEBUG: 1 });
expect(c.drain()).toEqual({});
});
});

describe('traceparent round-trip', () => {
it('round-trips every valid header', () => {
const header = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const parsed = parseTraceparent(header)!;
expect(formatTraceparent(parsed)).toBe(header);
});

it('refuses to emit a header its own parser would reject', () => {
expect(formatTraceparent({ traceId: '0'.repeat(32), spanId: '00f067aa0ba902b7', sampled: true })).toBeNull();
});
});

describe('loadTelemetrySettings — fail-safe', () => {
it('returns defaults with no provider at all (usable offline / in tests)', async () => {
await expect(loadTelemetrySettings()).resolves.toEqual(DEFAULT_TELEMETRY_SETTINGS);
});

it('returns defaults when the provider throws (config unreachable)', async () => {
const provider = vi.fn(() => {
throw new Error('ECONNREFUSED');
});
await expect(loadTelemetrySettings(provider)).resolves.toEqual(DEFAULT_TELEMETRY_SETTINGS);
expect(provider).toHaveBeenCalledOnce();
});

it('returns defaults when the provider rejects', async () => {
await expect(loadTelemetrySettings(() => Promise.reject(new Error('502')))).resolves.toEqual(DEFAULT_TELEMETRY_SETTINGS);
});

it('returns defaults on a malformed payload — never "sample everything out"', async () => {
for (const payload of [null, undefined, '<html>502</html>', 42, []]) {
const settings = await loadTelemetrySettings(() => payload);
expect(settings).toEqual(DEFAULT_TELEMETRY_SETTINGS);
expect(settings.browserLogSamplingRatio).toBe(1);
expect(settings.enabled).toBe(true);
}
});

it('applies a well-formed payload', async () => {
await expect(
loadTelemetrySettings(() => ({
[TELEMETRY_SETTING_KEYS.enabled]: true,
[TELEMETRY_SETTING_KEYS.browserLogSamplingRatio]: 0.2,
[TELEMETRY_SETTING_KEYS.minimumLogLevel]: 'warn',
[TELEMETRY_SETTING_KEYS.traceSamplingRatio]: 0.05,
})),
).resolves.toEqual({ enabled: true, browserLogSamplingRatio: 0.2, minimumLogLevel: 'WARN', traceSamplingRatio: 0.05 });
});
});
25 changes: 25 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,28 @@ export { Client } from './client';
// attributes to LLM/agent spans for the Smoo LLM dashboard + any
// GenAI-semconv-aware OTel backend (Datadog, Honeycomb, Phoenix, …).
export { setGenAIAttributes, recordGenAIMessage, type GenAIAttributes, type GenAIOperationName, type GenAISystem } from './gen-ai-attributes';
// ADR-097: session-scoped sampling, config-served telemetry settings, and W3C
// traceparent. Parity across the five SDKs is enforced by
// `parity/sampling-corpus.json` — see `parity/README.md`.
export {
fnv1a32,
sampleDecision,
shouldEmitLog,
normalizeLevel,
parseLevel,
meetsMinimumLevel,
createDropCounter,
LEVELS,
type CanonicalLevel,
type LogSamplingInput,
type DropCounter,
} from './sampling';
export {
DEFAULT_TELEMETRY_SETTINGS,
TELEMETRY_SETTING_KEYS,
resolveTelemetrySettings,
loadTelemetrySettings,
type TelemetrySettings,
type TelemetrySettingsProvider,
} from './telemetry-settings';
export { parseTraceparent, formatTraceparent, type TraceContext } from './traceparent';
Loading
Loading