From f059bb0f3aec99d2cd090ccb58a42d8a966d25b4 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 22 Aug 2026 11:40:28 -0500 Subject: [PATCH] feat(sdk): OpenTelemetry spans, without depending on OpenTelemetry A span around protect() and a child around rule evaluation, carrying the decision id (which joins a span to its dashboard row), the conclusion, the deciding rule, and whether the request cost a round trip to ingest. The tracer is INJECTED, not imported. An optional peer dependency was the obvious route and the wrong one: this package is dependency-free and passes an edge-compatibility gate, and Workers and Vercel Edge are exactly where a stray transitive import hurts. A conditional import('@opentelemetry/api') bundles badly too -- the bundler either resolves it, adding weight for the majority who do not use it, or fails on a module that is legitimately absent. The Tracer type is a structural subset of OpenTelemetry's, so trace.getTracer('webdecoy') satisfies it with no adapter. Omit it and there are no spans, no dependency and no behaviour change, which is the majority case and should cost them nothing. A tracer cannot break a request. startSpan returning nothing, throwing, or returning a span whose every method throws all degrade to a no-op -- observability that can take the request path down is worse than none. The no-op span also means call sites need no null checks, and an `if (span)` is a branch that gets forgotten on the path that mattered. Eight tests, four of them hostile tracers. Also asserts the span is ended on the ERROR path: a leaked span holds memory and never reaches the exporter, so the trace is silently incomplete rather than absent. Closes WebDecoy/app#740 --- CHANGELOG.md | 8 ++ README.md | 25 +++++ packages/webdecoy/src/index.ts | 1 + packages/webdecoy/src/sdk.ts | 51 ++++++++- packages/webdecoy/src/tracing.test.ts | 148 ++++++++++++++++++++++++++ packages/webdecoy/src/tracing.ts | 99 +++++++++++++++++ packages/webdecoy/src/types.ts | 16 +++ 7 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 packages/webdecoy/src/tracing.test.ts create mode 100644 packages/webdecoy/src/tracing.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2c3d7..a376701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **OpenTelemetry spans** around `protect()` and rule evaluation. Pass a tracer: `new WebDecoy({ tracer: trace.getTracer('webdecoy') })`. Injected rather than imported, so the package stays dependency-free and edge-safe — the `Tracer` type is a structural subset of OpenTelemetry's, so `trace.getTracer()` works with no adapter, and omitting it means no spans, no dependency and no behaviour change. Attributes cover the decision id (which joins a span to its dashboard row), the conclusion, the deciding rule, and whether the request cost a round trip to ingest. A tracer that throws cannot fail a request. + +### Changed + +- **One adapter core.** Express, Fastify, Next.js (middleware and Pages wrapper) and the fetch guard each carried their own copy of skip-path matching, the 429 and 403 payloads, and honeytoken arming — five copies of one set of decisions, and five places the next correction can fail to land. They now share `adapter-core.ts`; the framework-specific response mechanics are untouched, and every honeytoken-injection test passes unchanged. Fastify keeps its awaited arming, which has no window where early requests are served without the link. + ## [0.13.0] - 2026-08-22 ### Added diff --git a/README.md b/README.md index 7880bfd..3ec84a9 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,31 @@ new WebDecoy({ logger: fromPino(pino()) }); // pino's argument order is reverse `fromPino()` exists because passing a pino instance directly type-checks and then silently drops every structured field. +## Tracing + +Pass an OpenTelemetry tracer and `protect()` emits a span, with a child span for +rule evaluation: + +```typescript +import { trace } from '@opentelemetry/api'; + +new WebDecoy({ tracer: trace.getTracer('webdecoy') }); +``` + +The tracer is **injected, not imported** — this package has no dependencies and +runs on Workers and Vercel Edge, where a stray transitive import is expensive. +The `Tracer` type is a structural subset of OpenTelemetry's, so +`trace.getTracer()` satisfies it with no adapter. Omit it and there are no spans, +no dependency, and no behaviour change. + +Attributes are the questions an operator actually asks: `decision.id` (which +joins the span to the dashboard row), `decision.conclusion`, `decision.rule`, +`rules.evaluated`, and `webdecoy.remote` — whether the request cost a round trip +to ingest or was settled locally. + +A tracer that throws cannot fail a request. Observability that can take the +request path down is worse than none. + ## Examples See [examples](./examples) for complete working setups — e.g. [express-basic](./examples/express-basic). diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index d782046..86ceea0 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -84,6 +84,7 @@ export type { BotVerdict, BotAgent, BotCategory } from './bots'; // runtime with a fetch handler. `@webdecoy/hono` is a thin wrapper over it; Bun, // Deno, Astro and Nitro need no package at all. export { consoleLogger, silentLogger, fromPino } from './logger'; +export type { Tracer, Span } from './tracing'; export type { Logger, LogFields } from './logger'; // Browser signals, joined to the requests that follow them. See client-signals.ts diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index 03d2401..fa833e0 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -13,6 +13,8 @@ import { AgentVerifier } from './agent/verifier'; import type { AgentRequestInput, AgentVerdict } from './agent/types'; import { readEdgeVerdict } from './edge'; import { resolveLogger } from './logger'; +import { startSpan, setAttribute, recordError, endSpan } from './tracing'; +import type { Tracer } from './tracing'; import type { Logger } from './logger'; import { Decision, newDecisionId } from './decision'; import type { Conclusion } from './decision'; @@ -33,7 +35,7 @@ export class WebDecoy { private client: WebDecoyClient | null; private config: Omit< Required, - 'apiKey' | 'rules' | 'webBotAuth' | 'characteristics' | 'decisionCache' | 'logger' + 'apiKey' | 'rules' | 'webBotAuth' | 'characteristics' | 'decisionCache' | 'logger' | 'tracer' > & { apiKey?: string; }; @@ -48,6 +50,8 @@ export class WebDecoy { private readonly characteristics: readonly import('./characteristics').Characteristic[]; /** Where diagnostics go. Never console directly — see logger.ts. */ readonly log: Logger; + /** Optional OpenTelemetry tracer. Absent means no spans and no cost. */ + private readonly tracer?: Tracer; private readonly decisionCache: DecisionCache | null; constructor(config: WebDecoyConfig) { @@ -74,6 +78,7 @@ export class WebDecoy { }; this.log = resolveLogger(config.logger, this.config.debug); + this.tracer = config.tracer; // Initialize API client only when apiKey is provided if (hasApiKey) { @@ -253,7 +258,15 @@ export class WebDecoy { /** Evaluate rules against a prepared context and report any violations. */ private runRules(context: RuleContext): RuleEngineResult | null { if (!this.ruleEngine) return null; + + const span = startSpan(this.tracer, 'webdecoy.rules'); const result = this.ruleEngine.evaluate(context); + setAttribute(span, 'webdecoy.rules.action', result.action); + setAttribute(span, 'webdecoy.rules.evaluated', result.results.length); + setAttribute(span, 'webdecoy.rules.violations', result.violations.length); + if (result.rule) setAttribute(span, 'webdecoy.rules.deciding', result.rule); + endSpan(span); + if (result.violations.length > 0 && this.violationReporter) { this.violationReporter.report(result.violations); } @@ -335,7 +348,41 @@ export class WebDecoy { // present on every outcome — and a per-return copy is a line someone would // eventually forget on the branch that mattered. const edge = readEdgeVerdict(metadata.headers); - return (await this.decide(metadata, options)).withEdge(edge); + + const span = startSpan(this.tracer, 'webdecoy.protect'); + try { + const decision = (await this.decide(metadata, options)).withEdge(edge); + + // Attributes chosen so a trace answers the questions an operator actually + // asks: what did we decide, which rule decided it, and did this request + // cost a round trip to ingest. The decision id joins the span to the + // dashboard row. + setAttribute(span, 'webdecoy.decision.id', decision.id); + setAttribute(span, 'webdecoy.decision.conclusion', decision.conclusion); + setAttribute(span, 'webdecoy.decision.allowed', decision.allowed); + setAttribute(span, 'webdecoy.rules.evaluated', decision.results.length); + if (decision.ruleResult?.rule) { + setAttribute(span, 'webdecoy.decision.rule', decision.ruleResult.rule); + } + // A detection id that is not the decision id means the verdict came back + // from ingest rather than being settled locally. + setAttribute( + span, + 'webdecoy.remote', + decision.detection.detection_id !== decision.id, + ); + if (decision.error) { + setAttribute(span, 'webdecoy.error', decision.error); + } + return decision; + } catch (error) { + // decide() fails open rather than throwing, so this is a bug rather than + // a bad day — worth marking on the span rather than swallowing. + recordError(span, error); + throw error; + } finally { + endSpan(span); + } } private async decide( diff --git a/packages/webdecoy/src/tracing.test.ts b/packages/webdecoy/src/tracing.test.ts new file mode 100644 index 0000000..3709024 --- /dev/null +++ b/packages/webdecoy/src/tracing.test.ts @@ -0,0 +1,148 @@ +import { WebDecoy } from './sdk'; +import { tripwire, rateLimit } from './rules'; +import type { Span, Tracer } from './tracing'; +import type { RequestMetadata } from './types'; + +const req = (over: Partial = {}): RequestMetadata => ({ + method: 'GET', + path: '/', + ip: '203.0.113.9', + headers: {}, + timestamp: Date.now(), + ...over, +}); + +interface Recorded { + name: string; + attributes: Record; + ended: boolean; + errors: unknown[]; +} + +function recordingTracer() { + const spans: Recorded[] = []; + const tracer: Tracer = { + startSpan(name: string): Span { + const rec: Recorded = { name, attributes: {}, ended: false, errors: [] }; + spans.push(rec); + return { + setAttribute: (k, v) => { + rec.attributes[k] = v; + }, + recordException: (e) => rec.errors.push(e), + setStatus: () => undefined, + end: () => { + rec.ended = true; + }, + }; + }, + }; + return { tracer, spans }; +} + +describe('tracing', () => { + it('emits a span for protect() and one for rule evaluation', async () => { + const { tracer, spans } = recordingTracer(); + const wd = new WebDecoy({ tracer, rules: [tripwire()] }); + + await wd.protect(req({ path: '/.env' })); + + expect(spans.map((s) => s.name).sort()).toEqual(['webdecoy.protect', 'webdecoy.rules']); + expect(spans.every((s) => s.ended)).toBe(true); + }); + + it('records what an operator actually asks a trace', async () => { + const { tracer, spans } = recordingTracer(); + const wd = new WebDecoy({ tracer, rules: [tripwire()] }); + + const decision = await wd.protect(req({ path: '/.env' })); + const protectSpan = spans.find((s) => s.name === 'webdecoy.protect')!; + + // The id is what joins this span to the dashboard row. + expect(protectSpan.attributes['webdecoy.decision.id']).toBe(decision.id); + expect(protectSpan.attributes['webdecoy.decision.conclusion']).toBe('DENY'); + expect(protectSpan.attributes['webdecoy.decision.allowed']).toBe(false); + expect(protectSpan.attributes['webdecoy.decision.rule']).toBe('tripwire'); + // Settled locally: no round trip to ingest. + expect(protectSpan.attributes['webdecoy.remote']).toBe(false); + }); + + it('names the deciding rule on the rules span', async () => { + const { tracer, spans } = recordingTracer(); + const wd = new WebDecoy({ + tracer, + rules: [rateLimit({ max: 1, window: 60, action: 'DENY' })], + }); + + await wd.protect(req()); + await wd.protect(req()); + + const ruleSpans = spans.filter((s) => s.name === 'webdecoy.rules'); + expect(ruleSpans[0].attributes['webdecoy.rules.action']).toBe('ALLOW'); + expect(ruleSpans[1].attributes['webdecoy.rules.action']).toBe('DENY'); + expect(ruleSpans[1].attributes['webdecoy.rules.deciding']).toBe('rate-limit:1/60s'); + }); + + it('ends the span even when the decision is an error', async () => { + // A leaked span is worse than a missing one: it holds memory and never + // reaches the exporter, so the trace is silently incomplete. + const { tracer, spans } = recordingTracer(); + const wd = new WebDecoy({ tracer, rules: [] }); + + await wd.protect(req({ ip: '' })); // malformed — decide() fails open + + const protectSpan = spans.find((s) => s.name === 'webdecoy.protect')!; + expect(protectSpan.ended).toBe(true); + expect(protectSpan.attributes['webdecoy.decision.conclusion']).toBe('ERROR'); + expect(protectSpan.attributes['webdecoy.error']).toBeTruthy(); + }); +}); + +describe('a tracer must never be able to break a request', () => { + it('survives a tracer that throws on startSpan', async () => { + const wd = new WebDecoy({ + tracer: { + startSpan() { + throw new Error('exporter misconfigured'); + }, + }, + rules: [tripwire()], + }); + + // Observability that can take down the request path is worse than none. + const decision = await wd.protect(req({ path: '/.env' })); + expect(decision.conclusion).toBe('DENY'); + }); + + it('survives a tracer that throws on every method', async () => { + const hostile: Tracer = { + startSpan: () => + ({ + setAttribute() { + throw new Error('nope'); + }, + end() { + throw new Error('nope'); + }, + }) as unknown as Span, + }; + const wd = new WebDecoy({ tracer: hostile, rules: [tripwire()] }); + + const decision = await wd.protect(req({ path: '/.env' })); + expect(decision.conclusion).toBe('DENY'); + }); + + it('survives a tracer that returns nothing', async () => { + const wd = new WebDecoy({ + tracer: { startSpan: () => undefined as unknown as Span }, + rules: [tripwire()], + }); + expect((await wd.protect(req({ path: '/.env' }))).conclusion).toBe('DENY'); + }); + + it('costs nothing when no tracer is configured', async () => { + // The majority case. No spans, no dependency, no behaviour change. + const wd = new WebDecoy({ rules: [tripwire()] }); + expect((await wd.protect(req({ path: '/.env' }))).conclusion).toBe('DENY'); + }); +}); diff --git a/packages/webdecoy/src/tracing.ts b/packages/webdecoy/src/tracing.ts new file mode 100644 index 0000000..8c73832 --- /dev/null +++ b/packages/webdecoy/src/tracing.ts @@ -0,0 +1,99 @@ +/** + * OpenTelemetry spans, without depending on OpenTelemetry. + * + * WHY INJECTED RATHER THAN IMPORTED + * + * An optional peer dependency was the obvious route and the wrong one. This + * package is dependency-free and passes an edge-compatibility gate, and most of + * the runtimes it targets — Workers, Vercel Edge — are exactly where a stray + * transitive import hurts. A conditional `import('@opentelemetry/api')` also + * bundles badly: the bundler either resolves it, adding weight for the majority + * who do not use it, or fails on a module that is legitimately absent. + * + * So the tracer is passed in. The interface below is a structural subset of + * OpenTelemetry's, which means `trace.getTracer('webdecoy')` satisfies it + * directly with no adapter: + * + * ```ts + * import { trace } from '@opentelemetry/api'; + * new WebDecoy({ tracer: trace.getTracer('webdecoy') }); + * ``` + * + * An app that passes nothing gets no spans, no dependency, and no behaviour + * change — which is the majority, and they should not pay for this. + */ + +/** A span, structurally compatible with OpenTelemetry's. */ +export interface Span { + setAttribute(key: string, value: string | number | boolean): unknown; + recordException?(error: unknown): unknown; + setStatus?(status: { code: number; message?: string }): unknown; + end(): unknown; +} + +/** A tracer, structurally compatible with OpenTelemetry's. */ +export interface Tracer { + startSpan(name: string): Span; +} + +/** OpenTelemetry's SpanStatusCode.ERROR, inlined so the enum need not be imported. */ +const STATUS_ERROR = 2; + +/** + * A span that does nothing, so call sites need no null checks. + * + * Every `if (span)` is a branch that can be forgotten on the path that + * mattered, and a span left unended leaks. One object costs less than the + * discipline. + */ +const NOOP_SPAN: Span = { + setAttribute: () => undefined, + end: () => undefined, +}; + +/** + * Start a span, or hand back a no-op. + * + * Never throws. A tracer is observability, and observability that can take down + * the request path is worse than none — a misconfigured exporter must not + * become a 500 on a customer's site. + */ +export function startSpan(tracer: Tracer | undefined, name: string): Span { + if (!tracer) return NOOP_SPAN; + try { + return tracer.startSpan(name) ?? NOOP_SPAN; + } catch { + return NOOP_SPAN; + } +} + +/** Set an attribute, swallowing anything the tracer throws. */ +export function setAttribute(span: Span, key: string, value: string | number | boolean): void { + try { + span.setAttribute(key, value); + } catch { + // See startSpan: instrumentation must not be able to fail a request. + } +} + +/** Record a failure on the span, if the tracer supports it. */ +export function recordError(span: Span, error: unknown): void { + try { + span.recordException?.(error); + span.setStatus?.({ + code: STATUS_ERROR, + message: error instanceof Error ? error.message : String(error), + }); + } catch { + // As above. + } +} + +/** End a span, swallowing anything the tracer throws. */ +export function endSpan(span: Span): void { + try { + span.end(); + } catch { + // As above. + } +} diff --git a/packages/webdecoy/src/types.ts b/packages/webdecoy/src/types.ts index 2f74703..0ce8b0e 100644 --- a/packages/webdecoy/src/types.ts +++ b/packages/webdecoy/src/types.ts @@ -8,6 +8,7 @@ import type { AgentVerifierOptions } from './agent/types'; import type { Characteristic } from './characteristics'; import type { DecisionCacheOptions } from './decision-cache'; import type { Logger } from './logger'; +import type { Tracer } from './tracing'; export type { ProtectResult, Conclusion, RuleState, RuleOutcome } from './decision'; @@ -109,6 +110,21 @@ export interface WebDecoyConfig { * silently drops every structured field. */ logger?: Logger; + + /** + * An OpenTelemetry tracer, if you run one. + * + * Injected rather than imported so this package stays dependency-free and + * edge-safe. The interface is a structural subset of OpenTelemetry's, so + * `trace.getTracer('webdecoy')` works directly: + * + * ```ts + * new WebDecoy({ tracer: trace.getTracer('webdecoy') }); + * ``` + * + * Omit it and there are no spans, no dependency and no behaviour change. + */ + tracer?: Tracer; } /**