From da4a71740dc27ad98d41535977d80541a2891cfc Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Fri, 14 Aug 2026 18:38:04 +0200 Subject: [PATCH] feat: header-based client-type classification, user_type on spans --- CHANGELOG.md | 15 ++++++ README.md | 17 ++++++- package.json | 2 +- src/client-type.ts | 87 +++++++++++++++++++++++++++++++++++ src/index.ts | 10 ++++ src/mcp.ts | 3 ++ src/request-log.ts | 43 +++++++++++++++-- src/with-span.ts | 2 + test/client-type.test.ts | 87 +++++++++++++++++++++++++++++++++++ test/request-log.test.ts | 50 +++++++++++++++++++- test/stack-context-manager.ts | 30 ++++++++++++ 11 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 src/client-type.ts create mode 100644 test/client-type.test.ts create mode 100644 test/stack-context-manager.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2c9ca..cafa091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 34c423e..3b1e6f3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/package.json b/package.json index 00de8a9..00c774e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client-type.ts b/src/client-type.ts new file mode 100644 index 0000000..4395e14 --- /dev/null +++ b/src/client-type.ts @@ -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; +}; diff --git a/src/index.ts b/src/index.ts index 888674d..9aec7eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/mcp.ts b/src/mcp.ts index 65ca937..eafd5b7 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -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'; @@ -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); } diff --git a/src/request-log.ts b/src/request-log.ts index ddef755..3630dec 100644 --- a/src/request-log.ts +++ b/src/request-log.ts @@ -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. */ @@ -9,6 +17,7 @@ export interface RequestLogRequest { originalUrl?: string; baseUrl?: string; route?: { path?: unknown } | null; + headers?: Record; } export interface RequestLogResponse { @@ -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`. */ @@ -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; @@ -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', @@ -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(); + }); }; } diff --git a/src/with-span.ts b/src/with-span.ts index f82952e..5c8a7c7 100644 --- a/src/with-span.ts +++ b/src/with-span.ts @@ -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 @@ -18,6 +19,7 @@ export async function withSpan( return trace .getTracer('@agentage/observability') .startActiveSpan(name, { attributes }, async (span) => { + stampUserType(span); try { return await fn(span); } catch (err) { diff --git a/test/client-type.test.ts b/test/client-type.test.ts new file mode 100644 index 0000000..d555a0a --- /dev/null +++ b/test/client-type.test.ts @@ -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(); + }); +}); diff --git a/test/request-log.test.ts b/test/request-log.test.ts index bff4b64..fb63ffd 100644 --- a/test/request-log.test.ts +++ b/test/request-log.test.ts @@ -1,7 +1,15 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { context as otelContext, trace } from '@opentelemetry/api'; import { createRequestLog, type RequestLogRequest } from '../src/request-log.js'; +import { userTypeFromContext } from '../src/client-type.js'; +import { useStackContextManager } from './stack-context-manager.js'; import type { Logger } from 'pino'; +afterEach(() => { + otelContext.disable(); + vi.restoreAllMocks(); +}); + type LogRecord = Record; const run = ( @@ -51,9 +59,49 @@ describe('createRequestLog', () => { user_id: 'user_1', }); expect(typeof record.duration_ms).toBe('number'); + expect(record.user_type).toBe('user'); + }); + + it('classifies test traffic from the x-client-type header', () => { + const record = run({ + path: '/api/memories', + headers: { 'x-client-type': 'test', 'user-agent': 'Mozilla/5.0 Chrome/141' }, + }); + expect(record.user_type).toBe('test'); + }); + + it('falls back to the user agent and the path', () => { + expect( + run({ path: '/api/memories', headers: { 'user-agent': 'Playwright/1.55' } }).user_type + ).toBe('test'); + expect(run({ path: '/wp-login.php' }).user_type).toBe('bot'); + }); + + it('omits user_type when the injected classifier returns nothing', () => { + const record = run({ path: '/api/memories' }, { classify: () => undefined }); expect(record).not.toHaveProperty('user_type'); }); + it('stamps the active span and exposes user_type to descendants', () => { + useStackContextManager(); + const span = { setAttribute: vi.fn() }; + vi.spyOn(trace, 'getActiveSpan').mockReturnValue(span as never); + let seen: string | undefined; + createRequestLog({ info: vi.fn() } as unknown as Logger)( + { + method: 'GET', + path: '/api/memories', + headers: { 'x-client-type': 'test' }, + } as RequestLogRequest, + { statusCode: 200, on: () => {} }, + () => { + seen = userTypeFromContext(); + } + ); + expect(span.setAttribute).toHaveBeenCalledWith('user_type', 'test'); + expect(seen).toBe('test'); + }); + it('falls back to the url-derived route on a 404 (no req.route)', () => { const record = run({ method: 'POST', path: '/api/memories/42/notes' }, undefined, 404); expect(record.route).toBe('/api/memories/:id/notes'); diff --git a/test/stack-context-manager.ts b/test/stack-context-manager.ts new file mode 100644 index 0000000..24776b1 --- /dev/null +++ b/test/stack-context-manager.ts @@ -0,0 +1,30 @@ +import { context as otelContext, ROOT_CONTEXT, type Context } from '@opentelemetry/api'; + +/** + * The API ships a noop context manager, so baggage only survives `context.with` + * once one is registered (the NodeSDK registers AsyncLocalStorage in production). + * Synchronous stack is enough for the kit's tests. + */ +export function useStackContextManager(): void { + let active: Context = ROOT_CONTEXT; + otelContext.setGlobalContextManager({ + active: () => active, + with(ctx: Context, fn: (...a: unknown[]) => unknown, thisArg: unknown, ...args: unknown[]) { + const previous = active; + active = ctx; + try { + return fn.call(thisArg, ...args); + } finally { + active = previous; + } + }, + bind: (_ctx: Context, target: unknown) => target, + enable() { + return this; + }, + disable() { + active = ROOT_CONTEXT; + return this; + }, + } as never); +}