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; } /**