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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 0.16.0 - 2026-08-14

- `user_type` is now classified by the kit and lands on SPANS, not just the request
log line. `classifyClientType({ header, userAgent, path })` mirrors the estate
rules 1:1 (the `x-client-type` header wins - `service`/`test`; then a
playwright/headlesschrome/puppeteer UA; then bot UAs and scanner paths; else
`user`), so test traffic is declared rather than guessed.
- `createRequestLog` uses that classifier by default (an injected `classify` still
wins, `() => undefined` drops the field), stamps `user_type` on the request span
and puts it into OTel baggage. `withSpan` and `setMcpTool` stamp every span they
touch from that context; `stampUserType(span)` and `userTypeFromContext()` are
exported for spans you create yourself.
- Note: `classify` now runs at request ENTRY instead of at `finish`, so the value
exists while the request is still running.

## 0.15.1 - 2026-08-14

- `createRequestLog` captures the request identity once at middleware entry
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,20 @@ One line per finished request: `kind: 'http'`, `method`, `path`, `route` (templa
`status`, `duration_ms`, `user_id`, plus `trace_id`/`span_id` from the logger mixin. The
route falls back to a low-cardinality template derived from the URL when Express has no
matched route (404s) or matched by RegExp (a whole app mounted behind one pattern). Pass
`classify` to add `user_type`, and `userId` when the user does not live at `req.user.id`.
`userId` when the user does not live at `req.user.id`.

`user_type` classifies the traffic (`user` / `test` / `service` / `bot`) from the
`x-client-type` header first, then the user agent, then scanner-looking paths - test and
service callers declare themselves, they are not guessed. The middleware also puts the
value on the request's span as the `user_type` attribute and into OTel baggage, so the
admin console can drop test traffic from spans without regexing the user agent. Every span
`withSpan` creates and every span `setMcpTool` stamps inherits it; for a span you create
yourself, call `stampUserType(span)`. Pass your own `classify` to override the rule, or
`() => undefined` to drop the field.

```ts
import { classifyClientType, stampUserType, userTypeFromContext } from '@agentage/observability';
```

### Error events

Expand Down Expand Up @@ -268,6 +281,8 @@ without parsing the body.
| | `withSpan(name, fn, attrs?)` | Add depth deliberately; no-op without an SDK |
| | `setMcpTool`, `markSpanError`, `setSpanAttributes` | MCP tool-call span semantics |
| | `createRequestLog(log, options?)` | Express middleware: one wide event per request |
| | `classifyClientType(input)` | `user`/`test`/`service`/`bot` from header, UA, path |
| | `stampUserType(span?)`, `userTypeFromContext()` | Put `user_type` on spans you create yourself |
| | `errorMiddleware(log, options?)` | Express error handler emitting the `ErrorEvent` |
| | `onRequestError(log)` | Next `instrumentation.ts` error hook |
| | `wrapToolHandler(log, tool, handler)` | MCP tool errors, including `isError` results |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentage/observability",
"version": "0.15.1",
"version": "0.16.0",
"description": "Shared observability kit for agentage services: OTLP trace bootstrap (node --import), pino logger preset with trace correlation, one-call error capture, and the estate /health envelope.",
"type": "module",
"license": "MIT",
Expand Down
87 changes: 87 additions & 0 deletions src/client-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
context as otelContext,
propagation,
trace,
type Context,
type Span,
} from '@opentelemetry/api';

/**
* Identifies the origin of a request. Real browsers send no header; internal
* server-to-server calls and the e2e suite tag themselves, so test traffic is
* declared rather than guessed from the user agent.
*/
export const CLIENT_TYPE_HEADER = 'x-client-type';

/** The field every telemetry lane carries: request logs, spans, edge logs. */
export const USER_TYPE_FIELD = 'user_type';

export const UserType = {
User: 'user',
Test: 'test',
Service: 'service',
Bot: 'bot',
} as const;

export type UserType = (typeof UserType)[keyof typeof UserType];

const TEST_UA = /playwright|headlesschrome|puppeteer/;
const BOT_UA = /bot|crawl|spider|slurp|curl|wget|python-requests|scan/;
const BOT_PATH = /\.php$|\.env|\/wp-|\/\.git|\/vendor\/|phpunit|phpinfo/i;

export interface ClientTypeInput {
/** The `x-client-type` request header, if any. */
header?: string;
userAgent?: string;
path?: string;
}

/**
* Mirror of the estate classifier (web `packages/shared/src/client-type.ts` and
* the Vector VRL rules at the edge) - keep the three in lockstep, or the admin
* Traffic filter disagrees with itself across tabs. The header wins: it is the
* only signal a caller states about itself.
*/
export const classifyClientType = (input: ClientTypeInput): UserType => {
const header = (input.header ?? '').toLowerCase();
const ua = (input.userAgent ?? '').toLowerCase();
const path = input.path ?? '';
if (header === UserType.Service) return UserType.Service;
if (header === UserType.Test || TEST_UA.test(ua)) return UserType.Test;
if (BOT_UA.test(ua) || BOT_PATH.test(path)) return UserType.Bot;
return UserType.User;
};

const isUserType = (value: unknown): value is UserType =>
typeof value === 'string' && Object.values(UserType).includes(value as UserType);

/**
* A context carrying `user_type` in OTel baggage, so spans created anywhere
* under the request - including in code that never sees the request object -
* can stamp the same value.
*/
export const contextWithUserType = (
userType: UserType,
ctx: Context = otelContext.active()
): Context => {
const baggage = propagation.getBaggage(ctx) ?? propagation.createBaggage();
return propagation.setBaggage(ctx, baggage.setEntry(USER_TYPE_FIELD, { value: userType }));
};

/** The `user_type` carried by the active (or given) context, if it was classified. */
export const userTypeFromContext = (ctx: Context = otelContext.active()): UserType | undefined => {
const value = propagation.getBaggage(ctx)?.getEntry(USER_TYPE_FIELD)?.value;
return isUserType(value) ? value : undefined;
};

/**
* Stamp `user_type` on a span from the request's context - call it when you
* create a span the kit does not own (an MCP tool span, a worker job span).
* Returns the stamped value, or undefined when nothing was classified.
*/
export const stampUserType = (span?: Span): UserType | undefined => {
const userType = userTypeFromContext();
if (!userType) return undefined;
(span ?? trace.getActiveSpan())?.setAttribute(USER_TYPE_FIELD, userType);
return userType;
};
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export {
type ErrorCategory,
type ErrorFrameFields,
} from './error-frame.js';
export {
CLIENT_TYPE_HEADER,
USER_TYPE_FIELD,
UserType,
classifyClientType,
contextWithUserType,
stampUserType,
userTypeFromContext,
type ClientTypeInput,
} from './client-type.js';
export { tracedFetch, fetchTargetOf } from './traced-fetch.js';
export {
errorMiddleware,
Expand Down
3 changes: 3 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { trace, SpanStatusCode, type Attributes } from '@opentelemetry/api';
import type { Logger } from 'pino';
import { stampUserType } from './client-type.js';
import { captureError } from './errors.js';
import { redactArgs, errorCodeOf, fingerprintOf } from './error-event.js';

Expand All @@ -12,6 +13,8 @@ export function setMcpTool(tool: string, attributes?: Attributes): void {
const span = trace.getActiveSpan();
if (!span) return;
span.setAttribute('mcp.tool.name', tool);
// Tool spans are the admin console's MCP lane; without this it regexes the UA.
stampUserType(span);
if (attributes) span.setAttributes(attributes);
}

Expand Down
43 changes: 38 additions & 5 deletions src/request-log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { context as otelContext } from '@opentelemetry/api';
import type { Logger } from 'pino';
import {
CLIENT_TYPE_HEADER,
classifyClientType,
contextWithUserType,
stampUserType,
type UserType,
} from './client-type.js';
import { readableRoute, routeFromUrl } from './span-names.js';

/** Structurally typed so the kit stays dependency-light - no express import. */
Expand All @@ -9,6 +17,7 @@ export interface RequestLogRequest {
originalUrl?: string;
baseUrl?: string;
route?: { path?: unknown } | null;
headers?: Record<string, string | string[] | undefined>;
}

export interface RequestLogResponse {
Expand All @@ -18,9 +27,9 @@ export interface RequestLogResponse {

export interface RequestLogOptions {
/**
* Traffic classifier behind `user_type`. An injection point rather than a
* built-in: a shared classifier would drag a product dependency into the kit.
* The field is omitted when no classifier is given.
* Traffic classifier behind `user_type`. Defaults to the kit's
* `classifyClientType` over the `x-client-type` header, the user agent and the
* path; pass your own to override, or `() => undefined` to drop the field.
*/
classify?: (req: RequestLogRequest) => string | undefined;
/** Where the user id lives on your request; defaults to `req.user.id`. */
Expand All @@ -35,6 +44,20 @@ export type RequestLogMiddleware = (
next: () => void
) => void;

const header = (req: RequestLogRequest, name: string): string | undefined => {
const value = req.headers?.[name];
return Array.isArray(value) ? value[0] : value;
};

const defaultClassify =
(originalPath: string) =>
(req: RequestLogRequest): UserType =>
classifyClientType({
header: header(req, CLIENT_TYPE_HEADER),
userAgent: header(req, 'user-agent'),
path: originalPath,
});

const defaultUserId = (req: RequestLogRequest): string | undefined => {
const id = (req as { user?: { id?: unknown } }).user?.id;
return typeof id === 'string' ? id : undefined;
Expand Down Expand Up @@ -79,9 +102,11 @@ export function createRequestLog(
// Captured at entry: Express rewrites req.path/baseUrl to be router-relative
// once a mounted router handles the request, so at 'finish' it is truncated.
const originalPath = (req.originalUrl ?? req.path).split('?')[0];
// Classified at entry, not at 'finish': the span and every descendant need
// the value while the request is still running.
const userType = (options.classify ?? defaultClassify(originalPath))(req);
res.on('finish', () => {
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
const userType = options.classify?.(req);
log.info(
{
kind: 'http',
Expand All @@ -96,6 +121,14 @@ export function createRequestLog(
message
);
});
next();
// Only the canonical UserType values reach spans; a custom classifier's own
// vocabulary still lands on the log line.
if (userType === undefined) return next();
// Baggage, not just the span attribute: descendant spans are created by code
// that never sees the request.
otelContext.with(contextWithUserType(userType as UserType), () => {
stampUserType();
next();
});
};
}
2 changes: 2 additions & 0 deletions src/with-span.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { trace, SpanStatusCode, type Attributes, type Span } from '@opentelemetry/api';
import { stampUserType } from './client-type.js';

/**
* The intentional-instrumentation API: one call = a properly parented span with
Expand All @@ -18,6 +19,7 @@ export async function withSpan<T>(
return trace
.getTracer('@agentage/observability')
.startActiveSpan(name, { attributes }, async (span) => {
stampUserType(span);
try {
return await fn(span);
} catch (err) {
Expand Down
87 changes: 87 additions & 0 deletions test/client-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { context as otelContext, trace } from '@opentelemetry/api';
import { useStackContextManager } from './stack-context-manager.js';
import {
CLIENT_TYPE_HEADER,
classifyClientType,
contextWithUserType,
stampUserType,
userTypeFromContext,
} from '../src/client-type.js';

afterEach(() => {
otelContext.disable();
vi.restoreAllMocks();
});

describe('classifyClientType', () => {
it('lets the header win over a browser user agent', () => {
expect(CLIENT_TYPE_HEADER).toBe('x-client-type');
expect(classifyClientType({ header: 'test', userAgent: 'Mozilla/5.0 Chrome/141' })).toBe(
'test'
);
expect(classifyClientType({ header: 'Service', userAgent: 'Playwright/1.5' })).toBe('service');
});

it('falls back to the user agent when no header is sent', () => {
expect(classifyClientType({ userAgent: 'Mozilla/5.0 HeadlessChrome/141' })).toBe('test');
expect(classifyClientType({ userAgent: 'Playwright/1.55' })).toBe('test');
expect(classifyClientType({ userAgent: 'puppeteer' })).toBe('test');
expect(classifyClientType({ userAgent: 'curl/8.5.0' })).toBe('bot');
expect(classifyClientType({ userAgent: 'Googlebot/2.1' })).toBe('bot');
});

it('classifies scanner paths as bots and everything else as a user', () => {
expect(classifyClientType({ path: '/wp-login.php' })).toBe('bot');
expect(classifyClientType({ path: '/.git/config' })).toBe('bot');
expect(classifyClientType({ userAgent: 'Mozilla/5.0 Safari/605', path: '/memories' })).toBe(
'user'
);
expect(classifyClientType({})).toBe('user');
});

it('ignores an unknown header value', () => {
expect(classifyClientType({ header: 'robot', userAgent: 'Mozilla/5.0' })).toBe('user');
});
});

describe('user_type on spans', () => {
it('stamps the context value on the given span', () => {
const span = { setAttribute: vi.fn() };
otelContext.with(contextWithUserType('test'), () => {
// no context manager registered - read straight from the context instead
expect(userTypeFromContext(contextWithUserType('test'))).toBe('test');
});
useStackContextManager();
otelContext.with(contextWithUserType('test'), () => {
expect(stampUserType(span as never)).toBe('test');
});
expect(span.setAttribute).toHaveBeenCalledWith('user_type', 'test');
});

it('stamps the active span when none is given', () => {
useStackContextManager();
const span = { setAttribute: vi.fn() };
vi.spyOn(trace, 'getActiveSpan').mockReturnValue(span as never);
otelContext.with(contextWithUserType('bot'), () => stampUserType());
expect(span.setAttribute).toHaveBeenCalledWith('user_type', 'bot');
});

it('is a no-op when nothing was classified', () => {
const span = { setAttribute: vi.fn() };
expect(stampUserType(span as never)).toBeUndefined();
expect(span.setAttribute).not.toHaveBeenCalled();
expect(userTypeFromContext()).toBeUndefined();
});

it('propagates to nested contexts and keeps other baggage', () => {
useStackContextManager();
otelContext.with(contextWithUserType('service'), () => {
otelContext.with(contextWithUserType('service'), () => {
expect(userTypeFromContext()).toBe('service');
});
expect(userTypeFromContext()).toBe('service');
});
expect(userTypeFromContext()).toBeUndefined();
});
});
Loading