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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions packages/webdecoy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions packages/webdecoy/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,7 +35,7 @@ export class WebDecoy {
private client: WebDecoyClient | null;
private config: Omit<
Required<WebDecoyConfig>,
'apiKey' | 'rules' | 'webBotAuth' | 'characteristics' | 'decisionCache' | 'logger'
'apiKey' | 'rules' | 'webBotAuth' | 'characteristics' | 'decisionCache' | 'logger' | 'tracer'
> & {
apiKey?: string;
};
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
Expand Down
148 changes: 148 additions & 0 deletions packages/webdecoy/src/tracing.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): RequestMetadata => ({
method: 'GET',
path: '/',
ip: '203.0.113.9',
headers: {},
timestamp: Date.now(),
...over,
});

interface Recorded {
name: string;
attributes: Record<string, unknown>;
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');
});
});
99 changes: 99 additions & 0 deletions packages/webdecoy/src/tracing.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
Loading
Loading