From 863ccf3a39e465babcfd37f39749e5ea013fbff1 Mon Sep 17 00:00:00 2001 From: Polliog Date: Fri, 27 Feb 2026 23:54:56 +0100 Subject: [PATCH 1/7] feat(core): add SpanEvent type, enrich finishSpan, child span API, OTLP resource enrichment --- packages/core/src/child-span.ts | 29 +++++++++++++ packages/core/src/client.ts | 15 +++++-- packages/core/src/index.ts | 2 + packages/core/src/span-manager.ts | 18 +++++++- packages/core/src/transport/otlp-http.ts | 54 +++++++++++++++++++++--- packages/core/tests/client.test.ts | 18 ++++++++ packages/core/tests/span-manager.test.ts | 49 +++++++++++++++++++++ packages/types/src/index.ts | 2 +- packages/types/src/span.ts | 7 +++ 9 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/child-span.ts diff --git a/packages/core/src/child-span.ts b/packages/core/src/child-span.ts new file mode 100644 index 0000000..e455693 --- /dev/null +++ b/packages/core/src/child-span.ts @@ -0,0 +1,29 @@ +import type { Span, SpanAttributes } from '@logtide/types'; +import { hub } from './hub'; +import type { Scope } from './scope'; + +/** + * Start a child span under the given scope. + * If no client is registered, returns a no-op span. + */ +export function startChildSpan(name: string, scope: Scope, attributes?: SpanAttributes): Span { + const client = hub.getClient(); + if (!client) { + return { + traceId: scope.traceId, + spanId: '0000000000000000', + name, + status: 'unset', + startTime: Date.now(), + attributes: attributes ?? {}, + }; + } + return client.startSpan({ name, traceId: scope.traceId, parentSpanId: scope.spanId, attributes }); +} + +/** + * Finish a child span by ID via the hub client. + */ +export function finishChildSpan(spanId: string, status: 'ok' | 'error' = 'ok'): void { + hub.getClient()?.finishSpan(spanId, status); +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index efe6f26..3d4f78b 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -6,6 +6,8 @@ import type { Integration, LogLevel, Span, + SpanAttributes, + SpanEvent, Transport, } from '@logtide/types'; import { resolveDSN } from './dsn'; @@ -41,7 +43,10 @@ class DefaultTransport implements Transport { }); this.spanTransport = new BatchTransport({ - inner: new OtlpHttpTransport(dsn, options.service || 'unknown'), + inner: new OtlpHttpTransport(dsn, options.service || 'unknown', { + environment: options.environment, + release: options.release, + }), batchSize: options.batchSize, flushInterval: options.flushInterval, maxBufferSize: options.maxBufferSize, @@ -189,8 +194,12 @@ export class LogtideClient implements IClient { return this.spanManager.startSpan(options); } - finishSpan(spanId: string, status: 'ok' | 'error' = 'ok'): void { - const span = this.spanManager.finishSpan(spanId, status); + finishSpan( + spanId: string, + status: 'ok' | 'error' = 'ok', + options?: { extraAttributes?: SpanAttributes; events?: SpanEvent[] }, + ): void { + const span = this.spanManager.finishSpan(spanId, status, options); if (span && this.transport.sendSpans) { this.transport.sendSpans([span]); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed1ce83..1c9a47f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export type { Span, SpanStatus, SpanAttributes, + SpanEvent, Breadcrumb, BreadcrumbType, Transport, @@ -21,6 +22,7 @@ export { hub } from './hub'; export { Scope } from './scope'; export { SpanManager, type StartSpanOptions } from './span-manager'; export { BreadcrumbBuffer } from './breadcrumb-buffer'; +export { startChildSpan, finishChildSpan } from './child-span'; // DSN export { parseDSN, resolveDSN } from './dsn'; diff --git a/packages/core/src/span-manager.ts b/packages/core/src/span-manager.ts index 724d885..b8d1271 100644 --- a/packages/core/src/span-manager.ts +++ b/packages/core/src/span-manager.ts @@ -1,4 +1,4 @@ -import type { Span, SpanAttributes, SpanStatus } from '@logtide/types'; +import type { Span, SpanAttributes, SpanEvent, SpanStatus } from '@logtide/types'; import { generateSpanId, generateTraceId } from './utils/trace-id'; export interface StartSpanOptions { @@ -26,12 +26,26 @@ export class SpanManager { return span; } - finishSpan(spanId: string, status: SpanStatus = 'ok'): Span | undefined { + finishSpan( + spanId: string, + status: SpanStatus = 'ok', + options?: { extraAttributes?: SpanAttributes; events?: SpanEvent[] }, + ): Span | undefined { const span = this.activeSpans.get(spanId); if (!span) return undefined; span.endTime = Date.now(); span.status = status; + + if (options) { + if (options.extraAttributes) { + Object.assign(span.attributes, options.extraAttributes); + } + if (options.events && options.events.length > 0) { + span.events = options.events; + } + } + this.activeSpans.delete(spanId); return span; } diff --git a/packages/core/src/transport/otlp-http.ts b/packages/core/src/transport/otlp-http.ts index 973ee70..9ea9dab 100644 --- a/packages/core/src/transport/otlp-http.ts +++ b/packages/core/src/transport/otlp-http.ts @@ -5,14 +5,34 @@ import type { DSN } from '@logtide/types'; * Convert internal spans to OTLP JSON trace format. * Follows the OpenTelemetry Protocol (OTLP/HTTP) JSON specification. */ -function toOtlpTracePayload(spans: Span[], serviceName: string) { +function toOtlpTracePayload( + spans: Span[], + serviceName: string, + options?: { environment?: string; release?: string }, +) { + const resourceAttributes: { key: string; value: { stringValue: string } }[] = [ + { key: 'service.name', value: { stringValue: serviceName } }, + ]; + + if (options?.environment) { + resourceAttributes.push({ + key: 'deployment.environment', + value: { stringValue: options.environment }, + }); + } + + if (options?.release) { + resourceAttributes.push({ + key: 'service.version', + value: { stringValue: options.release }, + }); + } + return { resourceSpans: [ { resource: { - attributes: [ - { key: 'service.name', value: { stringValue: serviceName } }, - ], + attributes: resourceAttributes, }, scopeSpans: [ { @@ -37,6 +57,21 @@ function toOtlpTracePayload(spans: Span[], serviceName: string) { status: { code: s.status === 'error' ? 2 : s.status === 'ok' ? 1 : 0, }, + events: (s.events ?? []).map((e) => ({ + name: e.name, + timeUnixNano: String(e.timestamp * 1_000_000), + attributes: Object.entries(e.attributes ?? {}) + .filter(([, v]) => v !== undefined) + .map(([key, value]) => ({ + key, + value: + typeof value === 'number' + ? { intValue: String(value) } + : typeof value === 'boolean' + ? { boolValue: value } + : { stringValue: String(value) }, + })), + })), })), }, ], @@ -49,10 +84,14 @@ function toOtlpTracePayload(spans: Span[], serviceName: string) { export class OtlpHttpTransport implements Transport { private dsn: DSN; private serviceName: string; + private environment?: string; + private release?: string; - constructor(dsn: DSN, serviceName: string) { + constructor(dsn: DSN, serviceName: string, options?: { environment?: string; release?: string }) { this.dsn = dsn; this.serviceName = serviceName; + this.environment = options?.environment; + this.release = options?.release; } async sendLogs(_logs: InternalLogEntry[]): Promise { @@ -62,7 +101,10 @@ export class OtlpHttpTransport implements Transport { async sendSpans(spans: Span[]): Promise { if (spans.length === 0) return; - const payload = toOtlpTracePayload(spans, this.serviceName); + const payload = toOtlpTracePayload(spans, this.serviceName, { + environment: this.environment, + release: this.release, + }); const response = await fetch(`${this.dsn.apiUrl}/v1/otlp/traces`, { method: 'POST', diff --git a/packages/core/tests/client.test.ts b/packages/core/tests/client.test.ts index d464d20..5a4b4e8 100644 --- a/packages/core/tests/client.test.ts +++ b/packages/core/tests/client.test.ts @@ -107,6 +107,24 @@ describe('LogtideClient', () => { expect(transport.spans[0].endTime).toBeDefined(); }); + it('should finish a span with extraAttributes and events', () => { + const span = client.startSpan({ name: 'enriched-span', attributes: { 'http.method': 'POST' } }); + + client.finishSpan(span.spanId, 'ok', { + extraAttributes: { 'http.status_code': 201 }, + events: [{ name: 'breadcrumb', timestamp: 1234567890, attributes: { type: 'http' } }], + }); + + expect(transport.spans).toHaveLength(1); + const finished = transport.spans[0]; + expect(finished.status).toBe('ok'); + expect(finished.attributes['http.method']).toBe('POST'); + expect(finished.attributes['http.status_code']).toBe(201); + expect(finished.events).toHaveLength(1); + expect(finished.events![0].name).toBe('breadcrumb'); + expect(finished.events![0].timestamp).toBe(1234567890); + }); + it('should create a scope with traceId', () => { const scope = client.createScope('my-trace'); expect(scope.traceId).toBe('my-trace'); diff --git a/packages/core/tests/span-manager.test.ts b/packages/core/tests/span-manager.test.ts index 232d178..151c6aa 100644 --- a/packages/core/tests/span-manager.test.ts +++ b/packages/core/tests/span-manager.test.ts @@ -77,4 +77,53 @@ describe('SpanManager', () => { expect(finished!.status).toBe('ok'); }); + + it('should merge extraAttributes when provided via options', () => { + const span = sm.startSpan({ + name: 'attr-span', + attributes: { 'http.method': 'GET' }, + }); + const finished = sm.finishSpan(span.spanId, 'ok', { + extraAttributes: { 'http.status_code': 200, 'custom.key': 'value' }, + }); + + expect(finished).toBeDefined(); + expect(finished!.attributes['http.method']).toBe('GET'); + expect(finished!.attributes['http.status_code']).toBe(200); + expect(finished!.attributes['custom.key']).toBe('value'); + }); + + it('should set span events when provided via options', () => { + const span = sm.startSpan({ name: 'events-span' }); + const events = [ + { name: 'breadcrumb', timestamp: 1000, attributes: { type: 'http', message: 'GET /api' } }, + { name: 'breadcrumb', timestamp: 2000, attributes: { type: 'console', message: 'log entry' } }, + ]; + const finished = sm.finishSpan(span.spanId, 'ok', { events }); + + expect(finished).toBeDefined(); + expect(finished!.events).toHaveLength(2); + expect(finished!.events![0].name).toBe('breadcrumb'); + expect(finished!.events![0].timestamp).toBe(1000); + expect(finished!.events![0].attributes!['type']).toBe('http'); + expect(finished!.events![1].timestamp).toBe(2000); + }); + + it('should not set events when events array is empty', () => { + const span = sm.startSpan({ name: 'no-events-span' }); + const finished = sm.finishSpan(span.spanId, 'ok', { events: [] }); + + expect(finished).toBeDefined(); + expect(finished!.events).toBeUndefined(); + }); + + it('should remain backward compatible with no options argument', () => { + const span = sm.startSpan({ name: 'compat-span', attributes: { key: 'val' } }); + const finished = sm.finishSpan(span.spanId, 'ok'); + + expect(finished).toBeDefined(); + expect(finished!.status).toBe('ok'); + expect(finished!.attributes['key']).toBe('val'); + expect(finished!.events).toBeUndefined(); + }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d118559..a6d2fce 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,5 +1,5 @@ export type { LogLevel, LogEntry, InternalLogEntry } from './log'; -export type { Span, SpanStatus, SpanAttributes } from './span'; +export type { Span, SpanStatus, SpanAttributes, SpanEvent } from './span'; export type { Breadcrumb, BreadcrumbType } from './breadcrumb'; export type { Transport } from './transport'; export type { Integration, Client } from './integration'; diff --git a/packages/types/src/span.ts b/packages/types/src/span.ts index d99489f..fba985d 100644 --- a/packages/types/src/span.ts +++ b/packages/types/src/span.ts @@ -4,6 +4,12 @@ export interface SpanAttributes { [key: string]: string | number | boolean | undefined; } +export interface SpanEvent { + name: string; + timestamp: number; + attributes?: SpanAttributes; +} + export interface Span { traceId: string; spanId: string; @@ -13,4 +19,5 @@ export interface Span { startTime: number; endTime?: number; attributes: SpanAttributes; + events?: SpanEvent[]; } From b820989498c587da80ab42200dd9112d63230a85 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 28 Feb 2026 00:01:25 +0100 Subject: [PATCH 2/7] fix(core): fix TS narrowing in OTLP serializer, unify attr helpers, complete child-span API --- packages/core/src/child-span.ts | 10 +- packages/core/src/transport/otlp-http.ts | 38 ++++---- packages/core/tests/child-span.test.ts | 117 +++++++++++++++++++++++ 3 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 packages/core/tests/child-span.test.ts diff --git a/packages/core/src/child-span.ts b/packages/core/src/child-span.ts index e455693..9e0c8ad 100644 --- a/packages/core/src/child-span.ts +++ b/packages/core/src/child-span.ts @@ -1,4 +1,4 @@ -import type { Span, SpanAttributes } from '@logtide/types'; +import type { Span, SpanAttributes, SpanEvent } from '@logtide/types'; import { hub } from './hub'; import type { Scope } from './scope'; @@ -24,6 +24,10 @@ export function startChildSpan(name: string, scope: Scope, attributes?: SpanAttr /** * Finish a child span by ID via the hub client. */ -export function finishChildSpan(spanId: string, status: 'ok' | 'error' = 'ok'): void { - hub.getClient()?.finishSpan(spanId, status); +export function finishChildSpan( + spanId: string, + status: 'ok' | 'error' = 'ok', + options?: { extraAttributes?: SpanAttributes; events?: SpanEvent[] }, +): void { + hub.getClient()?.finishSpan(spanId, status, options); } diff --git a/packages/core/src/transport/otlp-http.ts b/packages/core/src/transport/otlp-http.ts index 9ea9dab..5ada5ef 100644 --- a/packages/core/src/transport/otlp-http.ts +++ b/packages/core/src/transport/otlp-http.ts @@ -1,6 +1,22 @@ import type { InternalLogEntry, Span, Transport } from '@logtide/types'; import type { DSN } from '@logtide/types'; +function serializeAttrValue( + value: string | number | boolean, +): { stringValue: string } | { intValue: string } | { boolValue: boolean } { + if (typeof value === 'number') return { intValue: String(value) }; + if (typeof value === 'boolean') return { boolValue: value }; + return { stringValue: value }; +} + +function serializeAttrs( + attrs: Record, +): { key: string; value: ReturnType }[] { + return (Object.entries(attrs) as [string, string | number | boolean | undefined][]) + .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined) + .map(([key, value]) => ({ key, value: serializeAttrValue(value) })); +} + /** * Convert internal spans to OTLP JSON trace format. * Follows the OpenTelemetry Protocol (OTLP/HTTP) JSON specification. @@ -45,32 +61,14 @@ function toOtlpTracePayload( kind: 2, // SPAN_KIND_SERVER startTimeUnixNano: String(s.startTime * 1_000_000), endTimeUnixNano: String((s.endTime ?? s.startTime) * 1_000_000), - attributes: Object.entries(s.attributes).map(([key, value]) => ({ - key, - value: - typeof value === 'string' - ? { stringValue: value } - : typeof value === 'number' - ? { intValue: String(value) } - : { boolValue: value }, - })), + attributes: serializeAttrs(s.attributes), status: { code: s.status === 'error' ? 2 : s.status === 'ok' ? 1 : 0, }, events: (s.events ?? []).map((e) => ({ name: e.name, timeUnixNano: String(e.timestamp * 1_000_000), - attributes: Object.entries(e.attributes ?? {}) - .filter(([, v]) => v !== undefined) - .map(([key, value]) => ({ - key, - value: - typeof value === 'number' - ? { intValue: String(value) } - : typeof value === 'boolean' - ? { boolValue: value } - : { stringValue: String(value) }, - })), + attributes: serializeAttrs(e.attributes ?? {}), })), })), }, diff --git a/packages/core/tests/child-span.test.ts b/packages/core/tests/child-span.test.ts new file mode 100644 index 0000000..45fa3cc --- /dev/null +++ b/packages/core/tests/child-span.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { Transport, InternalLogEntry, Span } from '@logtide/types'; +import { LogtideClient } from '../src/client'; +import { hub } from '../src/hub'; +import { Scope } from '../src/scope'; +import { startChildSpan, finishChildSpan } from '../src/child-span'; + +function createMockTransport(): Transport & { + logs: InternalLogEntry[]; + spans: Span[]; +} { + const transport = { + logs: [] as InternalLogEntry[], + spans: [] as Span[], + async sendLogs(logs: InternalLogEntry[]) { + transport.logs.push(...logs); + }, + async sendSpans(spans: Span[]) { + transport.spans.push(...spans); + }, + async flush() {}, + }; + return transport; +} + +describe('child-span', () => { + let transport: ReturnType; + + beforeEach(async () => { + // Ensure hub is clean before each test + await hub.close(); + transport = createMockTransport(); + }); + + afterEach(async () => { + await hub.close(); + }); + + describe('startChildSpan', () => { + it('creates a span with correct traceId and parentSpanId from scope when client is active', () => { + hub.init({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'test-service', + transport, + }); + + const scope = new Scope('abc123traceId00000000000000000000'); + scope.spanId = 'parentSpan000001'; + + const span = startChildSpan('child-op', scope, { 'http.method': 'GET' }); + + expect(span.traceId).toBe('abc123traceId00000000000000000000'); + expect(span.parentSpanId).toBe('parentSpan000001'); + expect(span.name).toBe('child-op'); + expect(span.attributes['http.method']).toBe('GET'); + // Should be a real span (not no-op) + expect(span.spanId).not.toBe('0000000000000000'); + }); + + it('returns a no-op span (spanId = 0000000000000000) when no client is registered', () => { + // hub is closed (no client), so getClient() returns null + const scope = new Scope('traceid-no-client-00000000000000'); + scope.spanId = 'parent-no-client-1'; + + const span = startChildSpan('noop-op', scope); + + expect(span.spanId).toBe('0000000000000000'); + expect(span.traceId).toBe('traceid-no-client-00000000000000'); + expect(span.name).toBe('noop-op'); + expect(span.status).toBe('unset'); + }); + }); + + describe('finishChildSpan', () => { + it('finishes a span with ok status when client is active', () => { + hub.init({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'test-service', + transport, + }); + + const client = hub.getClient()!; + const span = client.startSpan({ name: 'finish-test' }); + + finishChildSpan(span.spanId, 'ok'); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].status).toBe('ok'); + expect(transport.spans[0].endTime).toBeDefined(); + }); + + it('passes extraAttributes and events through to the client finishSpan', () => { + hub.init({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'test-service', + transport, + }); + + const client = hub.getClient()!; + const span = client.startSpan({ name: 'enriched-child-span', attributes: { 'http.method': 'POST' } }); + + finishChildSpan(span.spanId, 'ok', { + extraAttributes: { 'http.status_code': 201 }, + events: [{ name: 'response', timestamp: 1700000000000, attributes: { type: 'http' } }], + }); + + expect(transport.spans).toHaveLength(1); + const finished = transport.spans[0]; + expect(finished.status).toBe('ok'); + expect(finished.attributes['http.method']).toBe('POST'); + expect(finished.attributes['http.status_code']).toBe(201); + expect(finished.events).toHaveLength(1); + expect(finished.events![0].name).toBe('response'); + expect(finished.events![0].timestamp).toBe(1700000000000); + }); + }); +}); From 609ce298d4ebd27e35783dedf6ccabd251765849 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 28 Feb 2026 00:06:23 +0100 Subject: [PATCH 3/7] feat(express,fastify): richer spans - status code, route, duration, user agent, IP, breadcrumb events - Add http.user_agent, net.peer.ip, http.query_string attributes at span start - Populate request breadcrumb data field (method, url, userAgent) - Track startTime locally for accurate duration_ms calculation - Set http.status_code, http.route, duration_ms as extra attributes at span finish - Convert scope breadcrumbs to SpanEvent[] and pass to finishSpan for OTLP timeline - Add response breadcrumb (level-aware: error/warn/info) before finishing span - Include duration_ms in 5xx captureLog metadata - Add includeRequestBody / includeRequestHeaders opt-in options (with header sanitization) - Export logtideErrorHandler() from Express package for unhandled error capture --- packages/express/src/middleware.ts | 151 +++++++++++++++- packages/express/tests/middleware.test.ts | 206 +++++++++++++++++++++- packages/fastify/src/middleware.ts | 139 +++++++++++++-- packages/fastify/tests/middleware.test.ts | 164 +++++++++++++++++ 4 files changed, 642 insertions(+), 18 deletions(-) diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index f32bfc0..cc95d1a 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -1,6 +1,7 @@ import type { Request, Response, NextFunction } from 'express'; import type { ClientOptions } from '@logtide/types'; import type { Scope } from '@logtide/core'; +import type { SpanEvent } from '@logtide/core'; import { hub, ConsoleIntegration, @@ -10,7 +11,10 @@ import { createTraceparent, } from '@logtide/core'; -export interface LogtideExpressOptions extends ClientOptions {} +export interface LogtideExpressOptions extends ClientOptions { + includeRequestBody?: boolean; + includeRequestHeaders?: boolean | string[]; +} declare global { namespace Express { @@ -72,26 +76,52 @@ export function logtide(options: LogtideExpressOptions) { const method = req.method; const pathname = req.path || req.url; + // Collect optional start-time attributes + const userAgent = req.headers['user-agent']; + const clientIp = req.ip; + const queryString = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''; + + const startAttributes: Record = { + 'http.method': method, + 'http.url': req.originalUrl || req.url, + 'http.target': pathname, + }; + if (userAgent) { + startAttributes['http.user_agent'] = userAgent; + } + if (clientIp) { + startAttributes['net.peer.ip'] = clientIp; + } + if (queryString) { + startAttributes['http.query_string'] = queryString; + } + const span = client.startSpan({ name: `${method} ${pathname}`, traceId, parentSpanId, - attributes: { - 'http.method': method, - 'http.url': req.originalUrl || req.url, - 'http.target': pathname, - }, + attributes: startAttributes, }); scope.spanId = span.spanId; + // Request breadcrumb with data field + const fullUrl = req.originalUrl || req.url; scope.addBreadcrumb({ type: 'http', category: 'request', message: `${method} ${pathname}`, timestamp: Date.now(), + data: { + method, + url: fullUrl, + ...(userAgent ? { userAgent } : {}), + }, }); + // Record start time for duration calculation + const startTime = Date.now(); + // Make scope available on the request object req.logtideScope = scope; req.logtideTraceId = traceId; @@ -102,7 +132,87 @@ export function logtide(options: LogtideExpressOptions) { // Finish span when response completes res.on('finish', () => { const status = res.statusCode; - client.finishSpan(span.spanId, status >= 500 ? 'error' : 'ok'); + const durationMs = Date.now() - startTime; + + // Build extra attributes + const extraAttributes: Record = { + 'http.status_code': status, + 'duration_ms': durationMs, + }; + + // Add route template if available + const routePath = req.route?.path as string | undefined; + if (routePath != null) { + extraAttributes['http.route'] = routePath; + } + + // Opt-in request body capture + if (options.includeRequestBody && req.body != null) { + const bodyStr = JSON.stringify(req.body); + if (bodyStr && bodyStr !== '{}' && bodyStr !== 'null') { + extraAttributes['http.request_body'] = bodyStr.slice(0, 4096); + } + } + + // Opt-in request headers capture + if (options.includeRequestHeaders) { + const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'x-api-key', 'x-auth-token']); + let headersToCapture: Record; + + if (Array.isArray(options.includeRequestHeaders)) { + // Capture only specified headers — no sanitization needed + const specifiedHeaders = options.includeRequestHeaders; + headersToCapture = {}; + for (const headerName of specifiedHeaders) { + const val = req.headers[headerName.toLowerCase()]; + if (val !== undefined) { + headersToCapture[headerName.toLowerCase()] = Array.isArray(val) ? val.join(', ') : val; + } + } + } else { + // Capture all headers except sensitive ones + headersToCapture = {}; + for (const [key, val] of Object.entries(req.headers)) { + if (!SENSITIVE_HEADERS.has(key.toLowerCase()) && val !== undefined) { + headersToCapture[key] = Array.isArray(val) ? val.join(', ') : val; + } + } + } + + const headersStr = JSON.stringify(headersToCapture); + if (headersStr && headersStr !== '{}') { + extraAttributes['http.request_headers'] = headersStr.slice(0, 4096); + } + } + + // Add response breadcrumb BEFORE calling finishSpan so it's included in events + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} ${method} ${pathname}`, + level: status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + + // Convert breadcrumbs to SpanEvents + const events: SpanEvent[] = scope.getBreadcrumbs().map((bc) => ({ + name: bc.message, + timestamp: bc.timestamp, + attributes: bc.data + ? Object.fromEntries( + Object.entries(bc.data).map(([k, v]) => [ + k, + typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v), + ]), + ) + : undefined, + })); + + client.finishSpan(span.spanId, status >= 500 ? 'error' : 'ok', { + extraAttributes, + events, + }); if (status >= 500) { client.captureLog('error', `HTTP ${status} ${method} ${pathname}`, { @@ -110,6 +220,7 @@ export function logtide(options: LogtideExpressOptions) { 'http.url': req.originalUrl || req.url, 'http.target': pathname, 'http.status_code': String(status), + duration_ms: durationMs, }, scope); } }); @@ -117,3 +228,29 @@ export function logtide(options: LogtideExpressOptions) { next(); }; } + +/** + * Express error-handling middleware for LogTide — captures unhandled errors + * and associates them with the current request's trace scope. + * + * Must be registered AFTER your route handlers with four parameters so + * Express recognises it as an error handler. + * + * @example + * ```ts + * app.use(logtideErrorHandler()); + * ``` + */ +export function logtideErrorHandler() { + return (err: Error, req: Request, res: Response, next: NextFunction) => { + const client = hub.getClient(); + if (client && req.logtideScope) { + client.captureError(err, { + 'http.method': req.method, + 'http.url': req.originalUrl || req.url, + 'http.target': req.path || req.url, + }, req.logtideScope); + } + next(err); + }; +} diff --git a/packages/express/tests/middleware.test.ts b/packages/express/tests/middleware.test.ts index da00a2a..c7e60c1 100644 --- a/packages/express/tests/middleware.test.ts +++ b/packages/express/tests/middleware.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import express from 'express'; import { createServer, type Server } from 'http'; -import { logtide } from '../src/middleware'; +import { logtide, logtideErrorHandler } from '../src/middleware'; import type { InternalLogEntry, Span } from '@logtide/types'; function createMockTransport() { @@ -278,4 +278,208 @@ describe('@logtide/express middleware', () => { const res = await request(server, '/'); expect(res.status).toBe(200); }); + + // ─── New richer traces tests ──────────────────────────────────────────────── + + it('should set http.status_code attribute on span for 200', async () => { + const app = createApp(); + app.get('/status-200', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/status-200'); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(200); + }); + + it('should set http.status_code attribute on span for 404', async () => { + const app = createApp(); + app.get('/status-404', (_req, res) => { res.status(404).send('Not Found'); }); + + await listen(app); + await request(server, '/status-404'); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(404); + }); + + it('should set http.status_code attribute on span for 500', async () => { + const app = createApp(); + app.get('/status-500', (_req, res) => { res.status(500).send('Server Error'); }); + + await listen(app); + await request(server, '/status-500'); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(500); + }); + + it('should set http.user_agent when User-Agent header is provided', async () => { + const app = createApp(); + app.get('/ua', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/ua', { headers: { 'user-agent': 'TestAgent/1.0' } }); + + const span = transport.spans[0]; + expect(span.attributes['http.user_agent']).toBe('TestAgent/1.0'); + }); + + it('should not set http.user_agent when User-Agent header is absent', async () => { + const app = express(); + app.use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'express-test', + transport, + })); + app.get('/no-ua', (_req, res) => { res.send('ok'); }); + + await listen(app); + // fetch always sends a User-Agent; we can test that the attribute key exists only when provided + // Instead, verify presence when explicitly set + await request(server, '/no-ua', { headers: { 'user-agent': 'CustomAgent/2.0' } }); + + const span = transport.spans[0]; + expect(span.attributes['http.user_agent']).toBeDefined(); + }); + + it('should set duration_ms in span extraAttributes', async () => { + const app = createApp(); + app.get('/duration', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/duration'); + + const span = transport.spans[0]; + expect(span.attributes['duration_ms']).toBeGreaterThanOrEqual(0); + expect(typeof span.attributes['duration_ms']).toBe('number'); + }); + + it('should include breadcrumbs as span events (at least request + response)', async () => { + const app = createApp(); + app.get('/events', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/events'); + + const span = transport.spans[0]; + expect(span.events).toBeDefined(); + expect(span.events!.length).toBeGreaterThanOrEqual(2); + + // First event should be request breadcrumb + const requestEvent = span.events!.find(e => e.name.includes('GET /events')); + expect(requestEvent).toBeDefined(); + + // Should also have a response event + const responseEvent = span.events!.find(e => e.name.match(/^\d{3} GET \/events$/)); + expect(responseEvent).toBeDefined(); + }); + + it('should set http.query_string on span when query params are present', async () => { + const app = createApp(); + app.get('/search', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/search?q=hello&page=1'); + + const span = transport.spans[0]; + expect(span.attributes['http.query_string']).toBe('?q=hello&page=1'); + }); + + it('should set http.route when route matches', async () => { + const app = createApp(); + app.get('/users/:id', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/users/42'); + + const span = transport.spans[0]; + expect(span.attributes['http.route']).toBe('/users/:id'); + }); + + it('should include duration_ms in 5xx error log metadata', async () => { + const app = createApp(); + app.get('/err-log', (_req, res) => { res.status(500).send('Server Error'); }); + + await listen(app); + await request(server, '/err-log'); + + const errLog = transport.logs.find(l => l.level === 'error'); + expect(errLog).toBeDefined(); + expect(errLog!.metadata?.duration_ms).toBeDefined(); + expect(typeof errLog!.metadata?.duration_ms).toBe('number'); + }); + + it('should capture request headers when includeRequestHeaders is true', async () => { + const app = express(); + app.use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'express-test', + transport, + includeRequestHeaders: true, + })); + app.get('/headers', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/headers', { headers: { 'x-request-id': 'abc123' } }); + + const span = transport.spans[0]; + expect(span.attributes['http.request_headers']).toBeDefined(); + const headers = JSON.parse(span.attributes['http.request_headers'] as string); + // Sensitive headers must not be present + expect(headers['authorization']).toBeUndefined(); + expect(headers['cookie']).toBeUndefined(); + // Non-sensitive custom header should be present + expect(headers['x-request-id']).toBe('abc123'); + }); + + it('should capture only specified headers when includeRequestHeaders is a string array', async () => { + const app = express(); + app.use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'express-test', + transport, + includeRequestHeaders: ['x-request-id'], + })); + app.get('/headers-select', (_req, res) => { res.send('ok'); }); + + await listen(app); + await request(server, '/headers-select', { + headers: { 'x-request-id': 'req-42', 'x-other': 'ignored' }, + }); + + const span = transport.spans[0]; + expect(span.attributes['http.request_headers']).toBeDefined(); + const headers = JSON.parse(span.attributes['http.request_headers'] as string); + expect(headers['x-request-id']).toBe('req-42'); + expect(headers['x-other']).toBeUndefined(); + }); + + it('should export logtideErrorHandler that captures errors with captureError', async () => { + const app = express(); + app.use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'express-test', + transport, + })); + app.get('/throw', (_req, _res, next) => { + next(new Error('test error')); + }); + // Express error handler (4 params) + app.use(logtideErrorHandler()); + // Final fallback to avoid unhandled error in test + app.use((_err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).send('caught'); + }); + + await listen(app); + const res = await request(server, '/throw'); + + expect(res.status).toBe(500); + // captureError calls captureLog which sends a log + const errLogs = transport.logs.filter(l => l.level === 'error'); + expect(errLogs.length).toBeGreaterThanOrEqual(1); + const errLog = errLogs.find(l => l.message === 'test error'); + expect(errLog).toBeDefined(); + }); }); diff --git a/packages/fastify/src/middleware.ts b/packages/fastify/src/middleware.ts index 97147f8..0d5f9cf 100644 --- a/packages/fastify/src/middleware.ts +++ b/packages/fastify/src/middleware.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import type { ClientOptions } from '@logtide/types'; import type { Scope } from '@logtide/core'; +import type { SpanEvent } from '@logtide/core'; import { hub, ConsoleIntegration, @@ -11,7 +12,10 @@ import { } from '@logtide/core'; import fp from 'fastify-plugin'; -export interface LogtideFastifyOptions extends ClientOptions {} +export interface LogtideFastifyOptions extends ClientOptions { + includeRequestBody?: boolean; + includeRequestHeaders?: boolean | string[]; +} declare module 'fastify' { interface FastifyRequest { @@ -45,7 +49,13 @@ export const logtide = fp( }); // Store span IDs per request for cross-hook access - const requestSpans = new WeakMap(); + const requestSpans = new WeakMap(); fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { const client = hub.getClient(); @@ -72,26 +82,51 @@ export const logtide = fp( const method = request.method; const pathname = request.url.split('?')[0]; + // Collect optional start-time attributes + const userAgent = request.headers['user-agent']; + const clientIp = request.ip; + const queryString = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''; + + const startAttributes: Record = { + 'http.method': method, + 'http.url': request.url, + 'http.target': pathname, + }; + if (userAgent) { + startAttributes['http.user_agent'] = userAgent; + } + if (clientIp) { + startAttributes['net.peer.ip'] = clientIp; + } + if (queryString) { + startAttributes['http.query_string'] = queryString; + } + const span = client.startSpan({ name: `${method} ${pathname}`, traceId, parentSpanId, - attributes: { - 'http.method': method, - 'http.url': request.url, - 'http.target': pathname, - }, + attributes: startAttributes, }); scope.spanId = span.spanId; + // Request breadcrumb with data field scope.addBreadcrumb({ type: 'http', category: 'request', message: `${method} ${pathname}`, timestamp: Date.now(), + data: { + method, + url: request.url, + ...(userAgent ? { userAgent } : {}), + }, }); + // Record start time for duration calculation + const startTime = Date.now(); + // Make scope available on the request request.logtideScope = scope; request.logtideTraceId = traceId; @@ -100,7 +135,7 @@ export const logtide = fp( reply.header('traceparent', createTraceparent(traceId, span.spanId, true)); // Store span info for onResponse/onError hooks - requestSpans.set(request, { spanId: span.spanId, traceId, method, pathname }); + requestSpans.set(request, { spanId: span.spanId, traceId, method, pathname, startTime }); }); fastify.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => { @@ -109,7 +144,90 @@ export const logtide = fp( if (!client || !spanInfo) return; const status = reply.statusCode; - client.finishSpan(spanInfo.spanId, status >= 500 ? 'error' : 'ok'); + const durationMs = Date.now() - spanInfo.startTime; + const scope = request.logtideScope; + + // Build extra attributes + const extraAttributes: Record = { + 'http.status_code': status, + 'duration_ms': durationMs, + }; + + // Add route template if available + const routePath = + (request as any).routeOptions?.url ?? + (request as any).routerPath; + if (routePath != null) { + extraAttributes['http.route'] = routePath as string; + } + + // Opt-in request body capture + if (options.includeRequestBody && (request as any).body != null) { + const bodyStr = JSON.stringify((request as any).body); + if (bodyStr && bodyStr !== '{}' && bodyStr !== 'null') { + extraAttributes['http.request_body'] = bodyStr.slice(0, 4096); + } + } + + // Opt-in request headers capture + if (options.includeRequestHeaders) { + const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'x-api-key', 'x-auth-token']); + let headersToCapture: Record; + + if (Array.isArray(options.includeRequestHeaders)) { + const specifiedHeaders = options.includeRequestHeaders; + headersToCapture = {}; + for (const headerName of specifiedHeaders) { + const val = request.headers[headerName.toLowerCase()]; + if (val !== undefined) { + headersToCapture[headerName.toLowerCase()] = Array.isArray(val) ? val.join(', ') : val; + } + } + } else { + headersToCapture = {}; + for (const [key, val] of Object.entries(request.headers)) { + if (!SENSITIVE_HEADERS.has(key.toLowerCase()) && val !== undefined) { + headersToCapture[key] = Array.isArray(val) ? val.join(', ') : val; + } + } + } + + const headersStr = JSON.stringify(headersToCapture); + if (headersStr && headersStr !== '{}') { + extraAttributes['http.request_headers'] = headersStr.slice(0, 4096); + } + } + + // Add response breadcrumb to scope BEFORE calling finishSpan so it's included in events + if (scope) { + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} ${spanInfo.method} ${spanInfo.pathname}`, + level: status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + } + + // Convert breadcrumbs to SpanEvents + const events: SpanEvent[] = (scope?.getBreadcrumbs() ?? []).map((bc) => ({ + name: bc.message, + timestamp: bc.timestamp, + attributes: bc.data + ? Object.fromEntries( + Object.entries(bc.data).map(([k, v]) => [ + k, + typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v), + ]), + ) + : undefined, + })); + + client.finishSpan(spanInfo.spanId, status >= 500 ? 'error' : 'ok', { + extraAttributes, + events, + }); if (status >= 500) { client.captureLog('error', `HTTP ${status} ${spanInfo.method} ${spanInfo.pathname}`, { @@ -117,7 +235,8 @@ export const logtide = fp( 'http.url': request.url, 'http.target': spanInfo.pathname, 'http.status_code': String(status), - }, request.logtideScope); + duration_ms: durationMs, + }, scope); } }); diff --git a/packages/fastify/tests/middleware.test.ts b/packages/fastify/tests/middleware.test.ts index 06e956f..25dd3e9 100644 --- a/packages/fastify/tests/middleware.test.ts +++ b/packages/fastify/tests/middleware.test.ts @@ -264,4 +264,168 @@ describe('@logtide/fastify plugin', () => { const res = await app.inject({ method: 'GET', url: '/' }); expect(res.statusCode).toBe(200); }); + + // ─── New richer traces tests ──────────────────────────────────────────────── + + it('should set http.status_code attribute on span for 200', async () => { + const app = await buildApp(); + app.get('/status-200', async () => 'ok'); + + await app.inject({ method: 'GET', url: '/status-200' }); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(200); + }); + + it('should set http.status_code attribute on span for 404', async () => { + const app = await buildApp(); + app.get('/status-404', async (_request, reply) => { + reply.status(404).send('Not Found'); + }); + + await app.inject({ method: 'GET', url: '/status-404' }); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(404); + }); + + it('should set http.status_code attribute on span for 500', async () => { + const app = await buildApp(); + app.get('/status-500', async (_request, reply) => { + reply.status(500).send('Server Error'); + }); + + await app.inject({ method: 'GET', url: '/status-500' }); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(500); + }); + + it('should set http.user_agent when User-Agent header is provided', async () => { + const app = await buildApp(); + app.get('/ua', async () => 'ok'); + + await app.inject({ + method: 'GET', + url: '/ua', + headers: { 'user-agent': 'TestAgent/1.0' }, + }); + + const span = transport.spans[0]; + expect(span.attributes['http.user_agent']).toBe('TestAgent/1.0'); + }); + + it('should set duration_ms in span extraAttributes', async () => { + const app = await buildApp(); + app.get('/duration', async () => 'ok'); + + await app.inject({ method: 'GET', url: '/duration' }); + + const span = transport.spans[0]; + expect(span.attributes['duration_ms']).toBeGreaterThanOrEqual(0); + expect(typeof span.attributes['duration_ms']).toBe('number'); + }); + + it('should include breadcrumbs as span events (at least request + response)', async () => { + const app = await buildApp(); + app.get('/events', async () => 'ok'); + + await app.inject({ method: 'GET', url: '/events' }); + + const span = transport.spans[0]; + expect(span.events).toBeDefined(); + expect(span.events!.length).toBeGreaterThanOrEqual(2); + + // First event should be the request breadcrumb + const requestEvent = span.events!.find(e => e.name.includes('GET /events')); + expect(requestEvent).toBeDefined(); + + // Should also have a response event + const responseEvent = span.events!.find(e => e.name.match(/^\d{3} GET \/events$/)); + expect(responseEvent).toBeDefined(); + }); + + it('should set http.query_string on span when query params are present', async () => { + const app = await buildApp(); + app.get('/search', async () => 'ok'); + + await app.inject({ method: 'GET', url: '/search?q=hello&page=1' }); + + const span = transport.spans[0]; + expect(span.attributes['http.query_string']).toBe('?q=hello&page=1'); + }); + + it('should set http.route when route matches', async () => { + const app = await buildApp(); + app.get('/users/:id', async () => 'ok'); + + await app.inject({ method: 'GET', url: '/users/42' }); + + const span = transport.spans[0]; + // http.route should be the route template + expect(span.attributes['http.route']).toBe('/users/:id'); + }); + + it('should include duration_ms in 5xx error log metadata', async () => { + const app = await buildApp(); + app.get('/err-log', async (_request, reply) => { + reply.status(500).send('Server Error'); + }); + + await app.inject({ method: 'GET', url: '/err-log' }); + + const errLog = transport.logs.find(l => l.level === 'error'); + expect(errLog).toBeDefined(); + expect(errLog!.metadata?.duration_ms).toBeDefined(); + expect(typeof errLog!.metadata?.duration_ms).toBe('number'); + }); + + it('should capture request headers when includeRequestHeaders is true', async () => { + const customApp = Fastify(); + await customApp.register(logtide, { + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'fastify-test', + transport, + includeRequestHeaders: true, + }); + customApp.get('/headers', async () => 'ok'); + app = customApp; + + await customApp.inject({ + method: 'GET', + url: '/headers', + headers: { 'x-request-id': 'abc123' }, + }); + + const span = transport.spans[0]; + expect(span.attributes['http.request_headers']).toBeDefined(); + const headers = JSON.parse(span.attributes['http.request_headers'] as string); + expect(headers['authorization']).toBeUndefined(); + expect(headers['cookie']).toBeUndefined(); + expect(headers['x-request-id']).toBe('abc123'); + }); + + it('should capture only specified headers when includeRequestHeaders is a string array', async () => { + const customApp = Fastify(); + await customApp.register(logtide, { + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'fastify-test', + transport, + includeRequestHeaders: ['x-request-id'], + }); + customApp.get('/headers-select', async () => 'ok'); + app = customApp; + + await customApp.inject({ + method: 'GET', + url: '/headers-select', + headers: { 'x-request-id': 'req-42', 'x-other': 'ignored' }, + }); + + const span = transport.spans[0]; + expect(span.attributes['http.request_headers']).toBeDefined(); + const headers = JSON.parse(span.attributes['http.request_headers'] as string); + expect(headers['x-request-id']).toBe('req-42'); + expect(headers['x-other']).toBeUndefined(); + }); }); From 1b0919576a22fc5786fdf50f57cd544b4641de57 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 28 Feb 2026 00:12:47 +0100 Subject: [PATCH 4/7] fix(express,fastify): export logtideErrorHandler, fix status_code type, harden headers denylist, move startTime capture - Export logtideErrorHandler from @logtide/express public index - Change http.status_code in captureLog from String(status) to status (number) for type consistency with span extraAttributes - Move SENSITIVE_HEADERS Set to module level (avoid re-creating on every request); add set-cookie and proxy-authorization to the denylist - Move startTime = Date.now() before addBreadcrumb so it captures actual request receipt time - Merge duplicate type imports from @logtide/core into a single import line - Replace (request as any).body casts with (request as unknown as { body?: unknown }).body in Fastify middleware - Fix misleading test name: "should not set http.user_agent when User-Agent header is absent" -> "should set http.user_agent when User-Agent header is present" --- packages/express/src/index.ts | 2 +- packages/express/src/middleware.ts | 19 ++++++++++++------- packages/express/tests/middleware.test.ts | 4 +--- packages/fastify/src/middleware.ts | 23 ++++++++++++++--------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 0e3c00b..ad97b74 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -1 +1 @@ -export { logtide, type LogtideExpressOptions } from './middleware'; +export { logtide, logtideErrorHandler, type LogtideExpressOptions } from './middleware'; diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index cc95d1a..60f2adc 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -1,7 +1,6 @@ import type { Request, Response, NextFunction } from 'express'; import type { ClientOptions } from '@logtide/types'; -import type { Scope } from '@logtide/core'; -import type { SpanEvent } from '@logtide/core'; +import type { Scope, SpanEvent } from '@logtide/core'; import { hub, ConsoleIntegration, @@ -25,6 +24,15 @@ declare global { } } +const SENSITIVE_HEADERS = new Set([ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'proxy-authorization', +]); + /** * Express middleware for LogTide — auto request tracing, error capture, breadcrumbs. * @@ -73,6 +81,7 @@ export function logtide(options: LogtideExpressOptions) { } const scope = client.createScope(traceId); + const startTime = Date.now(); const method = req.method; const pathname = req.path || req.url; @@ -119,9 +128,6 @@ export function logtide(options: LogtideExpressOptions) { }, }); - // Record start time for duration calculation - const startTime = Date.now(); - // Make scope available on the request object req.logtideScope = scope; req.logtideTraceId = traceId; @@ -156,7 +162,6 @@ export function logtide(options: LogtideExpressOptions) { // Opt-in request headers capture if (options.includeRequestHeaders) { - const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'x-api-key', 'x-auth-token']); let headersToCapture: Record; if (Array.isArray(options.includeRequestHeaders)) { @@ -219,7 +224,7 @@ export function logtide(options: LogtideExpressOptions) { 'http.method': method, 'http.url': req.originalUrl || req.url, 'http.target': pathname, - 'http.status_code': String(status), + 'http.status_code': status, duration_ms: durationMs, }, scope); } diff --git a/packages/express/tests/middleware.test.ts b/packages/express/tests/middleware.test.ts index c7e60c1..2dc3d5c 100644 --- a/packages/express/tests/middleware.test.ts +++ b/packages/express/tests/middleware.test.ts @@ -325,7 +325,7 @@ describe('@logtide/express middleware', () => { expect(span.attributes['http.user_agent']).toBe('TestAgent/1.0'); }); - it('should not set http.user_agent when User-Agent header is absent', async () => { + it('should set http.user_agent when User-Agent header is present', async () => { const app = express(); app.use(logtide({ dsn: 'https://lp_key@api.logtide.dev/proj', @@ -335,8 +335,6 @@ describe('@logtide/express middleware', () => { app.get('/no-ua', (_req, res) => { res.send('ok'); }); await listen(app); - // fetch always sends a User-Agent; we can test that the attribute key exists only when provided - // Instead, verify presence when explicitly set await request(server, '/no-ua', { headers: { 'user-agent': 'CustomAgent/2.0' } }); const span = transport.spans[0]; diff --git a/packages/fastify/src/middleware.ts b/packages/fastify/src/middleware.ts index 0d5f9cf..6b14e0f 100644 --- a/packages/fastify/src/middleware.ts +++ b/packages/fastify/src/middleware.ts @@ -1,7 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import type { ClientOptions } from '@logtide/types'; -import type { Scope } from '@logtide/core'; -import type { SpanEvent } from '@logtide/core'; +import type { Scope, SpanEvent } from '@logtide/core'; import { hub, ConsoleIntegration, @@ -24,6 +23,15 @@ declare module 'fastify' { } } +const SENSITIVE_HEADERS = new Set([ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'proxy-authorization', +]); + /** * Fastify plugin for LogTide — auto request tracing, error capture, breadcrumbs. * @@ -79,6 +87,7 @@ export const logtide = fp( } const scope = client.createScope(traceId); + const startTime = Date.now(); const method = request.method; const pathname = request.url.split('?')[0]; @@ -124,9 +133,6 @@ export const logtide = fp( }, }); - // Record start time for duration calculation - const startTime = Date.now(); - // Make scope available on the request request.logtideScope = scope; request.logtideTraceId = traceId; @@ -162,8 +168,8 @@ export const logtide = fp( } // Opt-in request body capture - if (options.includeRequestBody && (request as any).body != null) { - const bodyStr = JSON.stringify((request as any).body); + if (options.includeRequestBody && (request as unknown as { body?: unknown }).body != null) { + const bodyStr = JSON.stringify((request as unknown as { body?: unknown }).body); if (bodyStr && bodyStr !== '{}' && bodyStr !== 'null') { extraAttributes['http.request_body'] = bodyStr.slice(0, 4096); } @@ -171,7 +177,6 @@ export const logtide = fp( // Opt-in request headers capture if (options.includeRequestHeaders) { - const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'x-api-key', 'x-auth-token']); let headersToCapture: Record; if (Array.isArray(options.includeRequestHeaders)) { @@ -234,7 +239,7 @@ export const logtide = fp( 'http.method': spanInfo.method, 'http.url': request.url, 'http.target': spanInfo.pathname, - 'http.status_code': String(status), + 'http.status_code': status, duration_ms: durationMs, }, scope); } From 7de1ef9d94aa45aa01d40ec76270c6180d3e82b2 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 28 Feb 2026 00:16:32 +0100 Subject: [PATCH 5/7] feat(hono,elysia): richer spans - status code, duration, user agent, breadcrumb events - Add http.user_agent, net.peer.ip (x-forwarded-for), http.query_string to span start attributes - Populate request breadcrumb data field with method, url, userAgent - Capture startTime before breadcrumb for accurate duration measurement - Add response breadcrumb (type/category/level/data) before finishSpan - Convert scope breadcrumbs to SpanEvent[] and pass via finishSpan events option - Include http.status_code and duration_ms in extraAttributes on finishSpan - Forward http.route from Hono routePath when available - Add duration_ms to 5xx captureLog metadata - Handle catch block in Hono with response breadcrumb + events on finishSpan - Handle onError in Elysia with response breadcrumb + events on finishSpan - Store startTime in Elysia spanMap entry for accurate per-request duration - Add richer traces tests for both packages (status code, user agent, duration, events, query string, ip, route data) --- packages/elysia/src/plugin.ts | 131 +++++++++++++++++--- packages/elysia/tests/plugin.test.ts | 151 +++++++++++++++++++++++ packages/hono/src/middleware.ts | 125 +++++++++++++++++-- packages/hono/tests/middleware.test.ts | 158 +++++++++++++++++++++++++ 4 files changed, 537 insertions(+), 28 deletions(-) diff --git a/packages/elysia/src/plugin.ts b/packages/elysia/src/plugin.ts index 42f6cd7..33c70d2 100644 --- a/packages/elysia/src/plugin.ts +++ b/packages/elysia/src/plugin.ts @@ -1,5 +1,5 @@ import type { ClientOptions } from '@logtide/types'; -import type { Scope } from '@logtide/core'; +import type { Scope, SpanEvent } from '@logtide/core'; import { hub, ConsoleIntegration, @@ -36,7 +36,7 @@ export function logtide(options: LogtideElysiaOptions) { ], }); - const spanMap = new WeakMap(); + const spanMap = new WeakMap(); return new Elysia({ name: '@logtide/elysia' }) .onRequest(({ request }) => { @@ -63,28 +63,53 @@ export function logtide(options: LogtideElysiaOptions) { const scope = client.createScope(traceId); const url = new URL(request.url); const method = request.method; + const pathname = url.pathname; + + // Collect start-time attributes + const userAgent = request.headers.get('user-agent'); + const forwardedFor = request.headers.get('x-forwarded-for'); + const queryString = url.search; + + const startAttributes: Record = { + 'http.method': method, + 'http.url': request.url, + 'http.target': pathname, + }; + if (userAgent) { + startAttributes['http.user_agent'] = userAgent; + } + if (forwardedFor) { + startAttributes['net.peer.ip'] = forwardedFor; + } + if (queryString) { + startAttributes['http.query_string'] = queryString; + } const span = client.startSpan({ - name: `${method} ${url.pathname}`, + name: `${method} ${pathname}`, traceId, parentSpanId, - attributes: { - 'http.method': method, - 'http.url': request.url, - 'http.target': url.pathname, - }, + attributes: startAttributes, }); scope.spanId = span.spanId; + // Capture startTime BEFORE adding the breadcrumb + const startTime = Date.now(); + scope.addBreadcrumb({ type: 'http', category: 'request', - message: `${method} ${url.pathname}`, + message: `${method} ${pathname}`, timestamp: Date.now(), + data: { + method, + url: request.url, + ...(userAgent ? { userAgent } : {}), + }, }); - spanMap.set(request, { spanId: span.spanId, scope, traceId }); + spanMap.set(request, { spanId: span.spanId, scope, traceId, startTime }); }) .onAfterHandle(({ request, set }) => { const client = hub.getClient(); @@ -92,25 +117,101 @@ export function logtide(options: LogtideElysiaOptions) { if (!client || !ctx) return; const status = typeof set.status === 'number' ? set.status : 200; - client.finishSpan(ctx.spanId, status >= 500 ? 'error' : 'ok'); + const { scope, spanId, traceId, startTime } = ctx; + const url = new URL(request.url); + const pathname = url.pathname; + const method = request.method; + const durationMs = Date.now() - startTime; + + // Add response breadcrumb BEFORE calling finishSpan so it's included in events + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} ${method} ${pathname}`, + level: status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + + // Convert breadcrumbs to SpanEvents + const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); + + const extraAttributes: Record = { + 'http.status_code': status, + 'duration_ms': durationMs, + }; + + client.finishSpan(spanId, status >= 500 ? 'error' : 'ok', { + extraAttributes, + events, + }); // Inject traceparent if (typeof set.headers === 'object' && set.headers !== null) { (set.headers as Record)['traceparent'] = - createTraceparent(ctx.traceId, ctx.spanId, true); + createTraceparent(traceId, spanId, true); } }) - .onError(({ request, error }) => { + .onError(({ request, error, set }) => { const client = hub.getClient(); const ctx = spanMap.get(request); if (!client) return; if (ctx) { - client.finishSpan(ctx.spanId, 'error'); + const { scope, spanId, startTime } = ctx; + const url = new URL(request.url); + const pathname = url.pathname; + const method = request.method; + const durationMs = Date.now() - startTime; + const status = typeof set?.status === 'number' ? set.status : 500; + + // Add response breadcrumb + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} ${method} ${pathname}`, + level: 'error', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + + // Convert breadcrumbs to SpanEvents + const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); + + client.finishSpan(spanId, 'error', { + extraAttributes: { + 'http.status_code': status, + 'duration_ms': durationMs, + }, + events, + }); + client.captureError(error, { 'http.url': request.url, 'http.method': request.method, - }, ctx.scope); + }, scope); } else { client.captureError(error, { 'http.url': request.url, diff --git a/packages/elysia/tests/plugin.test.ts b/packages/elysia/tests/plugin.test.ts index f534a81..5d73d39 100644 --- a/packages/elysia/tests/plugin.test.ts +++ b/packages/elysia/tests/plugin.test.ts @@ -89,4 +89,155 @@ describe('@logtide/elysia plugin', () => { const errLog = transport.logs.find(l => l.level === 'error'); expect(errLog).toBeDefined(); }); + + // ─── Richer traces tests ───────────────────────────────────────────────────── + + it('should set http.status_code attribute on span for 200', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/status-200', () => 'ok'); + + await app.handle(new Request('http://localhost/status-200')); + + const span = transport.spans.find(s => s.name.includes('/status-200')); + expect(span).toBeDefined(); + expect(span!.attributes['http.status_code']).toBe(200); + }); + + it('should set http.user_agent when User-Agent header is provided', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/ua', () => 'ok'); + + await app.handle(new Request('http://localhost/ua', { + headers: { 'user-agent': 'TestAgent/1.0' }, + })); + + const span = transport.spans.find(s => s.name.includes('/ua')); + expect(span).toBeDefined(); + expect(span!.attributes['http.user_agent']).toBe('TestAgent/1.0'); + }); + + it('should set duration_ms in span attributes', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/duration', () => 'ok'); + + await app.handle(new Request('http://localhost/duration')); + + const span = transport.spans.find(s => s.name.includes('/duration')); + expect(span).toBeDefined(); + expect(span!.attributes['duration_ms']).toBeGreaterThanOrEqual(0); + expect(typeof span!.attributes['duration_ms']).toBe('number'); + }); + + it('should include breadcrumbs as span events (at least request + response)', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/events', () => 'ok'); + + await app.handle(new Request('http://localhost/events')); + + const span = transport.spans.find(s => s.name.includes('/events')); + expect(span).toBeDefined(); + expect(span!.events).toBeDefined(); + expect(span!.events!.length).toBeGreaterThanOrEqual(2); + + // Should have a request event + const requestEvent = span!.events!.find(e => e.name.includes('GET /events')); + expect(requestEvent).toBeDefined(); + + // Should also have a response event + const responseEvent = span!.events!.find(e => e.name.match(/^\d{3} GET \/events$/)); + expect(responseEvent).toBeDefined(); + }); + + it('should set http.query_string on span when query params are present', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/search', () => 'ok'); + + await app.handle(new Request('http://localhost/search?q=hello&page=1')); + + const span = transport.spans.find(s => s.name.includes('/search')); + expect(span).toBeDefined(); + expect(span!.attributes['http.query_string']).toBe('?q=hello&page=1'); + }); + + it('should set net.peer.ip from x-forwarded-for header', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/ip', () => 'ok'); + + await app.handle(new Request('http://localhost/ip', { + headers: { 'x-forwarded-for': '1.2.3.4' }, + })); + + const span = transport.spans.find(s => s.name.includes('/ip')); + expect(span).toBeDefined(); + expect(span!.attributes['net.peer.ip']).toBe('1.2.3.4'); + }); + + it('should include request breadcrumb data field with method and url', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/data-check', () => 'ok'); + + await app.handle(new Request('http://localhost/data-check')); + + const span = transport.spans.find(s => s.name.includes('/data-check')); + expect(span).toBeDefined(); + expect(span!.events).toBeDefined(); + const requestEvent = span!.events!.find(e => e.name.includes('GET /data-check')); + expect(requestEvent).toBeDefined(); + expect(requestEvent!.attributes?.['data.method']).toBe('GET'); + expect(requestEvent!.attributes?.['data.url']).toBeDefined(); + }); + + it('should mark span as error on unhandled exceptions', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/boom', () => { + throw new Error('unexpected'); + }); + + const res = await app.handle(new Request('http://localhost/boom')); + expect(res.status).toBe(500); + + const span = transport.spans.find(s => s.name.includes('/boom')); + expect(span).toBeDefined(); + expect(span!.status).toBe('error'); + }); }); diff --git a/packages/hono/src/middleware.ts b/packages/hono/src/middleware.ts index 99f88a8..1245a2f 100644 --- a/packages/hono/src/middleware.ts +++ b/packages/hono/src/middleware.ts @@ -1,5 +1,5 @@ import type { ClientOptions } from '@logtide/types'; -import type { Scope } from '@logtide/core'; +import type { Scope, SpanEvent } from '@logtide/core'; import { hub, ConsoleIntegration, @@ -62,25 +62,50 @@ export function logtide(options: LogtideHonoOptions) { const scope = client.createScope(traceId); const url = new URL(c.req.url); const method = c.req.method; + const pathname = url.pathname; + + // Collect start-time attributes + const userAgent = c.req.header('user-agent'); + const forwardedFor = c.req.header('x-forwarded-for'); + const queryString = url.search; + + const startAttributes: Record = { + 'http.method': method, + 'http.url': c.req.url, + 'http.target': pathname, + }; + if (userAgent) { + startAttributes['http.user_agent'] = userAgent; + } + if (forwardedFor) { + startAttributes['net.peer.ip'] = forwardedFor; + } + if (queryString) { + startAttributes['http.query_string'] = queryString; + } const span = client.startSpan({ - name: `${method} ${url.pathname}`, + name: `${method} ${pathname}`, traceId, parentSpanId, - attributes: { - 'http.method': method, - 'http.url': c.req.url, - 'http.target': url.pathname, - }, + attributes: startAttributes, }); scope.spanId = span.spanId; + // Capture startTime BEFORE adding the breadcrumb + const startTime = Date.now(); + scope.addBreadcrumb({ type: 'http', category: 'request', - message: `${method} ${url.pathname}`, + message: `${method} ${pathname}`, timestamp: Date.now(), + data: { + method, + url: c.req.url, + ...(userAgent ? { userAgent } : {}), + }, }); // Make scope available via c.set() @@ -91,27 +116,101 @@ export function logtide(options: LogtideHonoOptions) { await next(); const status = c.res.status; - client.finishSpan(span.spanId, status >= 500 ? 'error' : 'ok'); + const durationMs = Date.now() - startTime; + + // Add response breadcrumb BEFORE calling finishSpan so it's included in events + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} ${method} ${pathname}`, + level: status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + + // Convert breadcrumbs to SpanEvents + const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); + + // Build extra attributes + const route = typeof (c.req as any).routePath === 'string' && (c.req as any).routePath !== '' + ? (c.req as any).routePath as string + : undefined; + + const extraAttributes: Record = { + 'http.status_code': status, + 'duration_ms': durationMs, + ...(route ? { 'http.route': route } : {}), + }; + + client.finishSpan(span.spanId, status >= 500 ? 'error' : 'ok', { + extraAttributes, + events, + }); // Hono catches handler errors internally and converts them to 500 responses, // so we also capture an error log when we detect a 5xx status. if (status >= 500) { - client.captureLog('error', `HTTP ${status} ${method} ${url.pathname}`, { + client.captureLog('error', `HTTP ${status} ${method} ${pathname}`, { 'http.method': method, 'http.url': c.req.url, - 'http.target': url.pathname, + 'http.target': pathname, 'http.status_code': String(status), + duration_ms: durationMs, }, scope); } // Inject traceparent into response c.res.headers.set('traceparent', createTraceparent(traceId, span.spanId, true)); } catch (error) { - client.finishSpan(span.spanId, 'error'); + const durationMs = Date.now() - startTime; + + // Add response breadcrumb for error case + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `500 ${method} ${pathname}`, + level: 'error', + timestamp: Date.now(), + data: { status: 500, duration_ms: durationMs }, + }); + + // Convert breadcrumbs to SpanEvents + const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); + + client.finishSpan(span.spanId, 'error', { + extraAttributes: { + 'http.status_code': 500, + 'duration_ms': durationMs, + }, + events, + }); + client.captureError(error, { 'http.method': method, 'http.url': c.req.url, - 'http.target': url.pathname, + 'http.target': pathname, }, scope); throw error; } diff --git a/packages/hono/tests/middleware.test.ts b/packages/hono/tests/middleware.test.ts index 1d33253..e103dd0 100644 --- a/packages/hono/tests/middleware.test.ts +++ b/packages/hono/tests/middleware.test.ts @@ -115,4 +115,162 @@ describe('@logtide/hono middleware', () => { const res = await app.request('/'); expect(res.status).toBe(200); }); + + // ─── Richer traces tests ───────────────────────────────────────────────────── + + it('should set http.status_code attribute on span for 200', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/status-200', (c) => c.text('ok')); + + await app.request('/status-200'); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(200); + }); + + it('should set http.status_code attribute on span for 404', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/status-404', (c) => c.text('Not Found', 404)); + + await app.request('/status-404'); + + const span = transport.spans[0]; + expect(span.attributes['http.status_code']).toBe(404); + }); + + it('should set http.user_agent when User-Agent header is provided', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/ua', (c) => c.text('ok')); + + await app.request('/ua', { + headers: { 'user-agent': 'TestAgent/1.0' }, + }); + + const span = transport.spans[0]; + expect(span.attributes['http.user_agent']).toBe('TestAgent/1.0'); + }); + + it('should set duration_ms in span attributes', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/duration', (c) => c.text('ok')); + + await app.request('/duration'); + + const span = transport.spans[0]; + expect(span.attributes['duration_ms']).toBeGreaterThanOrEqual(0); + expect(typeof span.attributes['duration_ms']).toBe('number'); + }); + + it('should include breadcrumbs as span events (at least request + response)', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/events', (c) => c.text('ok')); + + await app.request('/events'); + + const span = transport.spans[0]; + expect(span.events).toBeDefined(); + expect(span.events!.length).toBeGreaterThanOrEqual(2); + + // First event should be request breadcrumb + const requestEvent = span.events!.find(e => e.name.includes('GET /events')); + expect(requestEvent).toBeDefined(); + + // Should also have a response event + const responseEvent = span.events!.find(e => e.name.match(/^\d{3} GET \/events$/)); + expect(responseEvent).toBeDefined(); + }); + + it('should set http.query_string on span when query params are present', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/search', (c) => c.text('ok')); + + await app.request('http://localhost/search?q=hello&page=1'); + + const span = transport.spans[0]; + expect(span.attributes['http.query_string']).toBe('?q=hello&page=1'); + }); + + it('should include duration_ms in 5xx error log metadata', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/err-log', (c) => c.text('Server Error', 500)); + + await app.request('/err-log'); + + const errLog = transport.logs.find(l => l.level === 'error'); + expect(errLog).toBeDefined(); + expect(errLog!.metadata?.duration_ms).toBeDefined(); + expect(typeof errLog!.metadata?.duration_ms).toBe('number'); + }); + + it('should include request breadcrumb data field with method and url', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/data-check', (c) => c.text('ok')); + + await app.request('http://localhost/data-check'); + + const span = transport.spans[0]; + expect(span.events).toBeDefined(); + const requestEvent = span.events!.find(e => e.name.includes('GET /data-check')); + expect(requestEvent).toBeDefined(); + // data fields are prefixed with "data." in span event attributes + expect(requestEvent!.attributes?.['data.method']).toBe('GET'); + expect(requestEvent!.attributes?.['data.url']).toBeDefined(); + }); + + it('should set net.peer.ip from x-forwarded-for header', async () => { + const app = new Hono(); + app.use('*', logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'hono-test', + transport, + })); + app.get('/ip', (c) => c.text('ok')); + + await app.request('/ip', { + headers: { 'x-forwarded-for': '1.2.3.4' }, + }); + + const span = transport.spans[0]; + expect(span.attributes['net.peer.ip']).toBe('1.2.3.4'); + }); }); From 52b3533d6edfd05d834303a4e7eb3e2e82a58661 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 28 Feb 2026 00:20:58 +0100 Subject: [PATCH 6/7] fix(express,fastify): unify breadcrumb-to-event conversion with hono/elysia format Extract a local breadcrumbsToEvents helper in both packages and replace the old flat data-key conversion with the richer format that includes breadcrumb.type, breadcrumb.category, breadcrumb.level, and data.* prefixed keys, matching the implementation already used by @logtide/hono and @logtide/elysia. --- packages/express/src/middleware.ts | 28 ++++++++++++++++------------ packages/fastify/src/middleware.ts | 28 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 60f2adc..e674ba1 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -33,6 +33,21 @@ const SENSITIVE_HEADERS = new Set([ 'proxy-authorization', ]); +function breadcrumbsToEvents(scope: Scope): SpanEvent[] { + return scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); +} + /** * Express middleware for LogTide — auto request tracing, error capture, breadcrumbs. * @@ -201,18 +216,7 @@ export function logtide(options: LogtideExpressOptions) { }); // Convert breadcrumbs to SpanEvents - const events: SpanEvent[] = scope.getBreadcrumbs().map((bc) => ({ - name: bc.message, - timestamp: bc.timestamp, - attributes: bc.data - ? Object.fromEntries( - Object.entries(bc.data).map(([k, v]) => [ - k, - typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v), - ]), - ) - : undefined, - })); + const events: SpanEvent[] = breadcrumbsToEvents(scope); client.finishSpan(span.spanId, status >= 500 ? 'error' : 'ok', { extraAttributes, diff --git a/packages/fastify/src/middleware.ts b/packages/fastify/src/middleware.ts index 6b14e0f..3d114fa 100644 --- a/packages/fastify/src/middleware.ts +++ b/packages/fastify/src/middleware.ts @@ -32,6 +32,21 @@ const SENSITIVE_HEADERS = new Set([ 'proxy-authorization', ]); +function breadcrumbsToEvents(scope: Scope): SpanEvent[] { + return scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); +} + /** * Fastify plugin for LogTide — auto request tracing, error capture, breadcrumbs. * @@ -216,18 +231,7 @@ export const logtide = fp( } // Convert breadcrumbs to SpanEvents - const events: SpanEvent[] = (scope?.getBreadcrumbs() ?? []).map((bc) => ({ - name: bc.message, - timestamp: bc.timestamp, - attributes: bc.data - ? Object.fromEntries( - Object.entries(bc.data).map(([k, v]) => [ - k, - typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v), - ]), - ) - : undefined, - })); + const events: SpanEvent[] = scope ? breadcrumbsToEvents(scope) : []; client.finishSpan(spanInfo.spanId, status >= 500 ? 'error' : 'ok', { extraAttributes, From 2fc16fa5fb4a6306ed602507235fdf2654757dad Mon Sep 17 00:00:00 2001 From: Polliog Date: Sat, 28 Feb 2026 01:00:15 +0100 Subject: [PATCH 7/7] feat(core): introduce child spans API, enrich span attributes, and enhance breadcrumb events --- CHANGELOG.md | 18 +++ package.json | 2 +- packages/angular/package.json | 2 +- packages/angular/src/http-interceptor.ts | 49 +++++++-- packages/core/package.json | 2 +- packages/core/src/child-span.ts | 4 +- packages/core/src/client.ts | 23 ++++ packages/core/src/span-manager.ts | 2 +- packages/core/tests/client.test.ts | 18 +++ packages/core/tests/integration-trace.test.ts | 98 +++++++++++++++++ packages/elysia/package.json | 2 +- packages/elysia/src/plugin.ts | 41 +++---- packages/express/package.json | 2 +- packages/express/src/middleware.ts | 10 +- packages/fastify/package.json | 2 +- packages/fastify/src/middleware.ts | 10 +- packages/hono/package.json | 2 +- packages/hono/src/middleware.ts | 43 +++----- packages/nextjs/package.json | 2 +- packages/nextjs/src/server/request-handler.ts | 57 +++++++++- packages/nextjs/tests/server.test.ts | 103 +++++++++++++++++- packages/node/package.json | 2 +- packages/nuxt/package.json | 2 +- packages/nuxt/src/runtime/server-plugin.ts | 80 +++++++++++++- packages/sveltekit/package.json | 2 +- packages/sveltekit/src/server/index.ts | 69 +++++++++++- packages/sveltekit/tests/hooks.test.ts | 84 ++++++++++++++ packages/types/package.json | 2 +- 28 files changed, 640 insertions(+), 93 deletions(-) create mode 100644 packages/core/tests/integration-trace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43fb355..b3cd0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-02-28 + +### Added +- **OTLP Span Events**: Breadcrumbs are now automatically converted to OTLP Span Events, providing a detailed timeline of events within the trace viewer. +- **Child Spans API**: New `startChildSpan()` and `finishChildSpan()` APIs in `@logtide/core` to create hierarchical spans for operations like DB queries or external API calls. +- **Rich Span Attributes**: Added standardized attributes to request spans across all frameworks: + - `http.user_agent`, `net.peer.ip`, `http.query_string` (at start) + - `http.status_code`, `duration_ms`, `http.route` (at finish) +- **Express Error Handler**: Exported `logtideErrorHandler` to capture unhandled errors and associate them with the current request scope. + +### Changed +- **Enriched Breadcrumbs**: Request/Response breadcrumbs now include more metadata (`method`, `url`, `status`, `duration_ms`) by default. +- **Improved Nuxt Tracing**: Nitro plugin now accurately captures response status codes and durations. +- **Improved Angular Tracing**: `LogtideHttpInterceptor` now captures status codes for both successful and failed outgoing requests. + +### Fixed +- Fixed a bug in Nuxt Nitro plugin where spans were always marked as 'ok' regardless of the actual response status. + ## [0.5.6] - 2026-02-08 ### Changed diff --git a/package.json b/package.json index df565ed..76c3a74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "0.5.6", + "version": "0.6.0", "scripts": { "build": "pnpm -r --filter @logtide/* build", "test": "pnpm -r --filter @logtide/* test", diff --git a/packages/angular/package.json b/packages/angular/package.json index 3820129..39d8b3f 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/angular", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK integration for Angular — ErrorHandler, HTTP Interceptor, trace propagation", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/angular/src/http-interceptor.ts b/packages/angular/src/http-interceptor.ts index 4abeb37..8aab08f 100644 --- a/packages/angular/src/http-interceptor.ts +++ b/packages/angular/src/http-interceptor.ts @@ -5,9 +5,10 @@ import { HttpHandler, HttpEvent, HttpErrorResponse, + HttpResponse, } from '@angular/common/http'; import { Observable, tap } from 'rxjs'; -import { hub, createTraceparent, generateSpanId } from '@logtide/core'; +import { hub, createTraceparent } from '@logtide/core'; /** * Angular HTTP Interceptor that: @@ -26,6 +27,7 @@ export class LogtideHttpInterceptor implements HttpInterceptor { // Start a span for this outgoing request let spanId: string | undefined; + const startTime = Date.now(); if (client) { const span = client.startSpan({ @@ -35,6 +37,7 @@ export class LogtideHttpInterceptor implements HttpInterceptor { attributes: { 'http.method': req.method, 'http.url': req.urlWithParams, + 'http.target': req.url, }, }); @@ -52,22 +55,50 @@ export class LogtideHttpInterceptor implements HttpInterceptor { type: 'http', category: 'http.request', message: `${req.method} ${req.urlWithParams}`, - timestamp: Date.now(), + timestamp: startTime, data: { method: req.method, url: req.urlWithParams }, }); } return next.handle(clonedReq).pipe( tap({ - next: () => { - // On success, finish span - if (client && spanId) { - client.finishSpan(spanId, 'ok'); + next: (event: HttpEvent) => { + if (event instanceof HttpResponse) { + // On success, finish span with status code + if (client && spanId) { + const durationMs = Date.now() - startTime; + client.finishSpan(spanId, event.status >= 500 ? 'error' : 'ok', { + extraAttributes: { + 'http.status_code': event.status, + 'duration_ms': durationMs, + }, + }); + + hub.addBreadcrumb({ + type: 'http', + category: 'http.response', + message: `${req.method} ${req.urlWithParams} → ${event.status}`, + level: event.status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { + method: req.method, + url: req.urlWithParams, + status: event.status, + duration_ms: durationMs, + }, + }); + } } }, error: (error: HttpErrorResponse) => { + const durationMs = Date.now() - startTime; if (client && spanId) { - client.finishSpan(spanId, 'error'); + client.finishSpan(spanId, 'error', { + extraAttributes: { + 'http.status_code': error.status, + 'duration_ms': durationMs, + }, + }); } hub.addBreadcrumb({ @@ -81,13 +112,15 @@ export class LogtideHttpInterceptor implements HttpInterceptor { url: req.urlWithParams, status: error.status, statusText: error.statusText, + duration_ms: durationMs, }, }); hub.captureError(error, { 'http.method': req.method, 'http.url': req.urlWithParams, - 'http.status': error.status, + 'http.status_code': error.status, + 'duration_ms': durationMs, }); }, }), diff --git a/packages/core/package.json b/packages/core/package.json index 06beb10..3c185b0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/core", - "version": "0.5.6", + "version": "0.6.0", "description": "Core client, hub, scope, transports, and utilities for the LogTide SDK", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/core/src/child-span.ts b/packages/core/src/child-span.ts index 9e0c8ad..823af0c 100644 --- a/packages/core/src/child-span.ts +++ b/packages/core/src/child-span.ts @@ -18,7 +18,7 @@ export function startChildSpan(name: string, scope: Scope, attributes?: SpanAttr attributes: attributes ?? {}, }; } - return client.startSpan({ name, traceId: scope.traceId, parentSpanId: scope.spanId, attributes }); + return client.startChildSpan(name, scope, attributes); } /** @@ -29,5 +29,5 @@ export function finishChildSpan( status: 'ok' | 'error' = 'ok', options?: { extraAttributes?: SpanAttributes; events?: SpanEvent[] }, ): void { - hub.getClient()?.finishSpan(spanId, status, options); + hub.getClient()?.finishChildSpan(spanId, status, options); } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 3d4f78b..9914f0c 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -205,6 +205,29 @@ export class LogtideClient implements IClient { } } + /** + * Start a child span under the given scope. + */ + startChildSpan(name: string, scope: Scope, attributes?: SpanAttributes): Span { + return this.startSpan({ + name, + traceId: scope.traceId, + parentSpanId: scope.spanId, + attributes, + }); + } + + /** + * Finish a child span by ID. + */ + finishChildSpan( + spanId: string, + status: 'ok' | 'error' = 'ok', + options?: { extraAttributes?: SpanAttributes; events?: SpanEvent[] }, + ): void { + this.finishSpan(spanId, status, options); + } + // ─── Integrations ───────────────────────────────────── addIntegration(integration: Integration): void { diff --git a/packages/core/src/span-manager.ts b/packages/core/src/span-manager.ts index b8d1271..c7405cf 100644 --- a/packages/core/src/span-manager.ts +++ b/packages/core/src/span-manager.ts @@ -42,7 +42,7 @@ export class SpanManager { Object.assign(span.attributes, options.extraAttributes); } if (options.events && options.events.length > 0) { - span.events = options.events; + span.events = (span.events ?? []).concat(options.events); } } diff --git a/packages/core/tests/client.test.ts b/packages/core/tests/client.test.ts index 5a4b4e8..cc57a40 100644 --- a/packages/core/tests/client.test.ts +++ b/packages/core/tests/client.test.ts @@ -125,6 +125,24 @@ describe('LogtideClient', () => { expect(finished.events![0].timestamp).toBe(1234567890); }); + it('should start and finish child spans', () => { + const scope = client.createScope('parent-trace'); + scope.spanId = 'parent-span'; + + const child = client.startChildSpan('child-span', scope, { 'db.system': 'postgresql' }); + + expect(child.name).toBe('child-span'); + expect(child.traceId).toBe('parent-trace'); + expect(child.parentSpanId).toBe('parent-span'); + expect(child.attributes['db.system']).toBe('postgresql'); + + client.finishChildSpan(child.spanId, 'ok'); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].spanId).toBe(child.spanId); + expect(transport.spans[0].status).toBe('ok'); + }); + it('should create a scope with traceId', () => { const scope = client.createScope('my-trace'); expect(scope.traceId).toBe('my-trace'); diff --git a/packages/core/tests/integration-trace.test.ts b/packages/core/tests/integration-trace.test.ts new file mode 100644 index 0000000..f3e7612 --- /dev/null +++ b/packages/core/tests/integration-trace.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { LogtideClient } from '../src/client'; +import { hub } from '../src/hub'; +import { startChildSpan, finishChildSpan } from '../src/child-span'; +import type { Transport, InternalLogEntry, Span } from '@logtide/types'; + +function createMockTransport(): Transport & { spans: Span[] } { + const transport = { + logs: [], + spans: [] as Span[], + async sendLogs(logs: InternalLogEntry[]) {}, + async sendSpans(spans: Span[]) { + transport.spans.push(...spans); + }, + async flush() {}, + }; + return transport; +} + +describe('Trace Integration (Complete Payload)', () => { + let transport: ReturnType; + + beforeEach(async () => { + await hub.close(); + transport = createMockTransport(); + hub.init({ + dsn: 'https://key@api.logtide.dev/1', + service: 'test-api', + transport, + }); + }); + + it('should generate a complete trace with events, child spans and rich attributes', async () => { + const client = hub.getClient()!; + const scope = client.createScope('trace-123'); + + // 1. Inizio Root Span (es. Middleware HTTP) + const rootSpan = client.startSpan({ + name: 'GET /api/users', + traceId: scope.traceId, + attributes: { + 'http.method': 'GET', + 'http.url': 'https://api.example.com/api/users', + 'net.peer.ip': '127.0.0.1' + } + }); + scope.spanId = rootSpan.spanId; + + // 2. Aggiunta Breadcrumb (che diventeranno eventi) + scope.addBreadcrumb({ + type: 'auth', + category: 'middleware', + message: 'User authenticated', + data: { user_id: 'user_99' } + }); + + // 3. Esecuzione operazione figlia (es. Query DB) + const dbSpan = startChildSpan('SELECT users', scope, { 'db.system': 'postgresql' }); + // Simulazione lavoro... + finishChildSpan(dbSpan.spanId, 'ok'); + + // 4. Fine Root Span con attributi finali e conversione breadcrumb -> events + const events = scope.getBreadcrumbs().map(b => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { 'breadcrumb.type': b.type, ...Object.fromEntries(Object.entries(b.data || {}).map(([k,v]) => [`data.${k}`, String(v)])) } + })); + + client.finishSpan(rootSpan.spanId, 'ok', { + extraAttributes: { + 'http.status_code': 200, + 'duration_ms': 150 + }, + events + }); + + // VERIFICA + expect(transport.spans).toHaveLength(2); + + const root = transport.spans.find(s => s.name === 'GET /api/users')!; + const child = transport.spans.find(s => s.name === 'SELECT users')!; + + // Verifica Gerarchia + expect(child.parentSpanId).toBe(root.spanId); + expect(child.traceId).toBe(root.traceId); + + // Verifica Attributi Root (ora molto più ricchi) + expect(root.attributes['http.status_code']).toBe(200); + expect(root.attributes['duration_ms']).toBe(150); + expect(root.attributes['net.peer.ip']).toBe('127.0.0.1'); + + // Verifica Eventi (le breadcrumb sono ora nella timeline dello span) + expect(root.events).toBeDefined(); + expect(root.events).toHaveLength(1); + expect(root.events![0].name).toBe('User authenticated'); + expect(root.events![0].attributes!['data.user_id']).toBe('user_99'); + }); +}); diff --git a/packages/elysia/package.json b/packages/elysia/package.json index f0c61f4..df57764 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/elysia", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK plugin for Elysia — request tracing and error capture via lifecycle hooks", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/elysia/src/plugin.ts b/packages/elysia/src/plugin.ts index 33c70d2..67de443 100644 --- a/packages/elysia/src/plugin.ts +++ b/packages/elysia/src/plugin.ts @@ -12,6 +12,21 @@ import Elysia from 'elysia'; export interface LogtideElysiaOptions extends ClientOptions {} +function breadcrumbsToEvents(scope: Scope): SpanEvent[] { + return scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); +} + /** * Elysia plugin for LogTide — request tracing, error capture, breadcrumbs. * @@ -134,18 +149,7 @@ export function logtide(options: LogtideElysiaOptions) { }); // Convert breadcrumbs to SpanEvents - const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ - name: b.message, - timestamp: b.timestamp, - attributes: { - 'breadcrumb.type': b.type, - ...(b.category ? { 'breadcrumb.category': b.category } : {}), - ...(b.level ? { 'breadcrumb.level': b.level } : {}), - ...Object.fromEntries( - Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) - ), - }, - })); + const events: SpanEvent[] = breadcrumbsToEvents(scope); const extraAttributes: Record = { 'http.status_code': status, @@ -187,18 +191,7 @@ export function logtide(options: LogtideElysiaOptions) { }); // Convert breadcrumbs to SpanEvents - const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ - name: b.message, - timestamp: b.timestamp, - attributes: { - 'breadcrumb.type': b.type, - ...(b.category ? { 'breadcrumb.category': b.category } : {}), - ...(b.level ? { 'breadcrumb.level': b.level } : {}), - ...Object.fromEntries( - Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) - ), - }, - })); + const events: SpanEvent[] = breadcrumbsToEvents(scope); client.finishSpan(spanId, 'error', { extraAttributes: { diff --git a/packages/express/package.json b/packages/express/package.json index 814895a..b5e0495 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/express", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK middleware for Express — request tracing and error capture", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index e674ba1..5cc15fa 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -169,9 +169,13 @@ export function logtide(options: LogtideExpressOptions) { // Opt-in request body capture if (options.includeRequestBody && req.body != null) { - const bodyStr = JSON.stringify(req.body); - if (bodyStr && bodyStr !== '{}' && bodyStr !== 'null') { - extraAttributes['http.request_body'] = bodyStr.slice(0, 4096); + try { + const bodyStr = JSON.stringify(req.body); + if (bodyStr && bodyStr !== '{}' && bodyStr !== 'null') { + extraAttributes['http.request_body'] = bodyStr.slice(0, 4096); + } + } catch { + // Ignore stringification errors for circular structures } } diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 4f52ec6..8a58af0 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/fastify", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK plugin for Fastify — request tracing and error capture", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/fastify/src/middleware.ts b/packages/fastify/src/middleware.ts index 3d114fa..73ad149 100644 --- a/packages/fastify/src/middleware.ts +++ b/packages/fastify/src/middleware.ts @@ -184,9 +184,13 @@ export const logtide = fp( // Opt-in request body capture if (options.includeRequestBody && (request as unknown as { body?: unknown }).body != null) { - const bodyStr = JSON.stringify((request as unknown as { body?: unknown }).body); - if (bodyStr && bodyStr !== '{}' && bodyStr !== 'null') { - extraAttributes['http.request_body'] = bodyStr.slice(0, 4096); + try { + const bodyStr = JSON.stringify((request as unknown as { body?: unknown }).body); + if (bodyStr && bodyStr !== '{}' && bodyStr !== 'null') { + extraAttributes['http.request_body'] = bodyStr.slice(0, 4096); + } + } catch { + // Ignore stringification errors for circular structures } } diff --git a/packages/hono/package.json b/packages/hono/package.json index d8d9318..53146fa 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/hono", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK middleware for Hono — request tracing and error capture", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/hono/src/middleware.ts b/packages/hono/src/middleware.ts index 1245a2f..f0a75d8 100644 --- a/packages/hono/src/middleware.ts +++ b/packages/hono/src/middleware.ts @@ -12,6 +12,21 @@ import { createMiddleware } from 'hono/factory'; export interface LogtideHonoOptions extends ClientOptions {} +function breadcrumbsToEvents(scope: Scope): SpanEvent[] { + return scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); +} + /** * Hono middleware for LogTide — auto request tracing, error capture, breadcrumbs. * @@ -129,18 +144,7 @@ export function logtide(options: LogtideHonoOptions) { }); // Convert breadcrumbs to SpanEvents - const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ - name: b.message, - timestamp: b.timestamp, - attributes: { - 'breadcrumb.type': b.type, - ...(b.category ? { 'breadcrumb.category': b.category } : {}), - ...(b.level ? { 'breadcrumb.level': b.level } : {}), - ...Object.fromEntries( - Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) - ), - }, - })); + const events: SpanEvent[] = breadcrumbsToEvents(scope); // Build extra attributes const route = typeof (c.req as any).routePath === 'string' && (c.req as any).routePath !== '' @@ -165,7 +169,7 @@ export function logtide(options: LogtideHonoOptions) { 'http.method': method, 'http.url': c.req.url, 'http.target': pathname, - 'http.status_code': String(status), + 'http.status_code': status, duration_ms: durationMs, }, scope); } @@ -186,18 +190,7 @@ export function logtide(options: LogtideHonoOptions) { }); // Convert breadcrumbs to SpanEvents - const events: SpanEvent[] = scope.getBreadcrumbs().map((b) => ({ - name: b.message, - timestamp: b.timestamp, - attributes: { - 'breadcrumb.type': b.type, - ...(b.category ? { 'breadcrumb.category': b.category } : {}), - ...(b.level ? { 'breadcrumb.level': b.level } : {}), - ...Object.fromEntries( - Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) - ), - }, - })); + const events: SpanEvent[] = breadcrumbsToEvents(scope); client.finishSpan(span.spanId, 'error', { extraAttributes: { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 7e0017f..18ca3d2 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/nextjs", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK integration for Next.js — auto error capture, request tracing, and performance spans", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/nextjs/src/server/request-handler.ts b/packages/nextjs/src/server/request-handler.ts index fca4087..13fe2c5 100644 --- a/packages/nextjs/src/server/request-handler.ts +++ b/packages/nextjs/src/server/request-handler.ts @@ -1,4 +1,20 @@ import { hub, Scope, parseTraceparent, generateTraceId, createTraceparent } from '@logtide/core'; +import type { SpanEvent } from '@logtide/core'; + +function breadcrumbsToEvents(scope: Scope): SpanEvent[] { + return scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); +} /** * Wraps a Next.js request to auto-create a span and propagate trace context. @@ -6,7 +22,7 @@ import { hub, Scope, parseTraceparent, generateTraceId, createTraceparent } from */ export function instrumentRequest( request: { headers: Headers; method: string; url: string }, -): { traceId: string; spanId: string; scope: Scope } | null { +): { traceId: string; spanId: string; scope: Scope; startTime: number } | null { const client = hub.getClient(); if (!client) return null; @@ -30,8 +46,16 @@ export function instrumentRequest( // Create a scope for this request const scope = client.createScope(traceId); + // Capture extra request metadata + const userAgent = request.headers.get('user-agent'); + const forwardedFor = request.headers.get('x-forwarded-for'); + // Start a server span const url = new URL(request.url, 'http://localhost'); + const queryString = url.search; + + const startTime = Date.now(); + const span = client.startSpan({ name: `${request.method} ${url.pathname}`, traceId, @@ -40,6 +64,9 @@ export function instrumentRequest( 'http.method': request.method, 'http.url': request.url, 'http.target': url.pathname, + ...(userAgent ? { 'http.user_agent': userAgent } : {}), + ...(forwardedFor ? { 'net.peer.ip': forwardedFor } : {}), + ...(queryString ? { 'http.query_string': queryString } : {}), }, }); @@ -50,10 +77,10 @@ export function instrumentRequest( category: 'request', message: `${request.method} ${url.pathname}`, timestamp: Date.now(), - data: { method: request.method, url: request.url }, + data: { method: request.method, url: request.url, ...(userAgent ? { userAgent } : {}) }, }); - return { traceId, spanId: span.spanId, scope }; + return { traceId, spanId: span.spanId, scope, startTime }; } /** @@ -62,9 +89,31 @@ export function instrumentRequest( export function finishRequest( spanId: string, statusCode: number, + scope: Scope, + startTime: number, + route?: string, ): void { const client = hub.getClient(); if (!client) return; - client.finishSpan(spanId, statusCode >= 500 ? 'error' : 'ok'); + const durationMs = Date.now() - startTime; + + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${statusCode} request`, + level: statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status: statusCode, duration_ms: durationMs }, + }); + + const extraAttributes: Record = { + 'http.status_code': statusCode, + 'duration_ms': durationMs, + ...(route ? { 'http.route': route } : {}), + }; + + const events: SpanEvent[] = breadcrumbsToEvents(scope); + + client.finishSpan(spanId, statusCode >= 500 ? 'error' : 'ok', { extraAttributes, events }); } diff --git a/packages/nextjs/tests/server.test.ts b/packages/nextjs/tests/server.test.ts index aecabca..03dc797 100644 --- a/packages/nextjs/tests/server.test.ts +++ b/packages/nextjs/tests/server.test.ts @@ -49,6 +49,7 @@ describe('@logtide/nextjs server', () => { expect(result!.spanId).toMatch(/^[0-9a-f]{16}$/); expect(result!.scope).toBeDefined(); expect(result!.scope.traceId).toBe(result!.traceId); + expect(typeof result!.startTime).toBe('number'); }); it('should extract trace context from traceparent header', async () => { @@ -82,6 +83,38 @@ describe('@logtide/nextjs server', () => { expect(bcs[0].message).toContain('GET /dashboard'); }); + it('should capture user-agent in span attributes', async () => { + const { instrumentRequest, finishRequest } = await import('../src/server/request-handler'); + + const request = { + headers: new Headers({ 'user-agent': 'TestAgent/1.0' }), + method: 'GET', + url: 'http://localhost:3000/api/ua-test', + }; + + const result = instrumentRequest(request); + finishRequest(result!.spanId, 200, result!.scope, result!.startTime); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].attributes?.['http.user_agent']).toBe('TestAgent/1.0'); + }); + + it('should capture query string in span attributes', async () => { + const { instrumentRequest, finishRequest } = await import('../src/server/request-handler'); + + const request = { + headers: new Headers(), + method: 'GET', + url: 'http://localhost:3000/api/search?q=hello&page=2', + }; + + const result = instrumentRequest(request); + finishRequest(result!.spanId, 200, result!.scope, result!.startTime); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].attributes?.['http.query_string']).toBe('?q=hello&page=2'); + }); + it('should return null when no client is initialized', async () => { await hub.close(); const { instrumentRequest } = await import('../src/server/request-handler'); @@ -107,7 +140,7 @@ describe('@logtide/nextjs server', () => { }; const result = instrumentRequest(request); - finishRequest(result!.spanId, 200); + finishRequest(result!.spanId, 200, result!.scope, result!.startTime); expect(transport.spans).toHaveLength(1); expect(transport.spans[0].status).toBe('ok'); @@ -123,11 +156,77 @@ describe('@logtide/nextjs server', () => { }; const result = instrumentRequest(request); - finishRequest(result!.spanId, 500); + finishRequest(result!.spanId, 500, result!.scope, result!.startTime); expect(transport.spans).toHaveLength(1); expect(transport.spans[0].status).toBe('error'); }); + + it('should record http.status_code in span attributes', async () => { + const { instrumentRequest, finishRequest } = await import('../src/server/request-handler'); + + const request = { + headers: new Headers(), + method: 'GET', + url: 'http://localhost:3000/api/data', + }; + + const result = instrumentRequest(request); + finishRequest(result!.spanId, 201, result!.scope, result!.startTime); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].attributes?.['http.status_code']).toBe(201); + }); + + it('should record duration_ms in span attributes', async () => { + const { instrumentRequest, finishRequest } = await import('../src/server/request-handler'); + + const request = { + headers: new Headers(), + method: 'GET', + url: 'http://localhost:3000/api/slow', + }; + + const result = instrumentRequest(request); + finishRequest(result!.spanId, 200, result!.scope, result!.startTime); + + expect(transport.spans).toHaveLength(1); + expect(typeof transport.spans[0].attributes?.['duration_ms']).toBe('number'); + }); + + it('should attach breadcrumb events to span', async () => { + const { instrumentRequest, finishRequest } = await import('../src/server/request-handler'); + + const request = { + headers: new Headers(), + method: 'GET', + url: 'http://localhost:3000/api/events', + }; + + const result = instrumentRequest(request); + finishRequest(result!.spanId, 200, result!.scope, result!.startTime); + + expect(transport.spans).toHaveLength(1); + // Should have at least the request + response breadcrumb events + expect(transport.spans[0].events).toBeDefined(); + expect(transport.spans[0].events!.length).toBeGreaterThanOrEqual(2); + }); + + it('should include http.route when route is provided', async () => { + const { instrumentRequest, finishRequest } = await import('../src/server/request-handler'); + + const request = { + headers: new Headers(), + method: 'GET', + url: 'http://localhost:3000/api/users/123', + }; + + const result = instrumentRequest(request); + finishRequest(result!.spanId, 200, result!.scope, result!.startTime, '/api/users/:id'); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].attributes?.['http.route']).toBe('/api/users/:id'); + }); }); describe('captureRequestError', () => { diff --git a/packages/node/package.json b/packages/node/package.json index cec79e5..a740504 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/sdk-node", - "version": "0.5.6", + "version": "0.6.0", "description": "Official Node.js SDK for LogTide (logtide.dev) - Self-hosted log management with advanced features: retry logic, circuit breaker, query API, live streaming, and middleware support", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 1e1ef73..7430884 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/nuxt", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK integration for Nuxt — auto error capture, request tracing via Nitro hooks", "type": "module", "main": "./dist/module.cjs", diff --git a/packages/nuxt/src/runtime/server-plugin.ts b/packages/nuxt/src/runtime/server-plugin.ts index 5dd9e50..0b8006f 100644 --- a/packages/nuxt/src/runtime/server-plugin.ts +++ b/packages/nuxt/src/runtime/server-plugin.ts @@ -5,7 +5,23 @@ import { generateTraceId, parseTraceparent, } from '@logtide/core'; -import { defineNitroPlugin, getRequestURL, getRequestHeaders } from 'h3'; +import type { Scope, SpanEvent } from '@logtide/core'; +import { defineNitroPlugin, getRequestURL, getRequestHeaders, getRequestIP } from 'h3'; + +function breadcrumbsToEvents(scope: Scope): SpanEvent[] { + return scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); +} /** * Nitro server plugin — hooks into request, afterResponse, and error lifecycle. @@ -54,6 +70,9 @@ export default defineNitroPlugin((nitroApp) => { const url = getRequestURL(event); const method = event.method ?? 'GET'; + const userAgent = headers['user-agent']; + const ip = getRequestIP(event); + const startTime = Date.now(); const scope = client.createScope(traceId); const span = client.startSpan({ @@ -64,33 +83,82 @@ export default defineNitroPlugin((nitroApp) => { 'http.method': method, 'http.url': url.href, 'http.target': url.pathname, + ...(userAgent ? { 'http.user_agent': userAgent } : {}), + ...(ip ? { 'net.peer.ip': ip } : {}), + ...(url.search ? { 'http.query_string': url.search } : {}), }, }); scope.spanId = span.spanId; + scope.addBreadcrumb({ + type: 'http', + category: 'request', + message: `${method} ${url.pathname}`, + timestamp: Date.now(), + data: { method, url: url.href, ...(userAgent ? { userAgent } : {}) }, + }); + // Store on event context for afterResponse / error hooks - (event.context as Record).__logtide = { scope, spanId: span.spanId }; + (event.context as Record).__logtide = { scope, spanId: span.spanId, startTime }; }); nitroApp.hooks.hook('afterResponse', (event) => { const ctx = (event.context as Record).__logtide as - | { spanId: string } + | { scope: Scope; spanId: string; startTime: number } | undefined; if (ctx) { - client.finishSpan(ctx.spanId, 'ok'); + const status = event.node.res.statusCode; + const durationMs = Date.now() - ctx.startTime; + const method = event.method ?? 'GET'; + const url = getRequestURL(event); + + ctx.scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} ${method} ${url.pathname}`, + level: status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + + client.finishSpan(ctx.spanId, status >= 500 ? 'error' : 'ok', { + extraAttributes: { + 'http.status_code': status, + 'duration_ms': durationMs, + }, + events: breadcrumbsToEvents(ctx.scope), + }); } }); nitroApp.hooks.hook('error', (error, { event }) => { const ctx = event ? ((event.context as Record).__logtide as - | { scope: ReturnType; spanId: string } + | { scope: Scope; spanId: string; startTime: number } | undefined) : undefined; if (ctx) { - client.finishSpan(ctx.spanId, 'error'); + const status = event?.node?.res?.statusCode || 500; + const durationMs = Date.now() - ctx.startTime; + + ctx.scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} error`, + level: 'error', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + + client.finishSpan(ctx.spanId, 'error', { + extraAttributes: { + 'http.status_code': status, + 'duration_ms': durationMs, + }, + events: breadcrumbsToEvents(ctx.scope), + }); client.captureError(error, {}, ctx.scope); } else { client.captureError(error); diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 0b5dc4f..909e193 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/sveltekit", - "version": "0.5.6", + "version": "0.6.0", "description": "LogTide SDK integration for SvelteKit — handle, handleError, handleFetch hooks", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index f8e0a08..e571172 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -1,12 +1,14 @@ import type { ClientOptions } from '@logtide/types'; import { hub, + Scope, ConsoleIntegration, GlobalErrorIntegration, generateTraceId, parseTraceparent, createTraceparent, } from '@logtide/core'; +import type { SpanEvent } from '@logtide/core'; interface HandleInput { event: { @@ -35,6 +37,21 @@ interface HandleFetchInput { fetch: typeof globalThis.fetch; } +function breadcrumbsToEvents(scope: Scope): SpanEvent[] { + return scope.getBreadcrumbs().map((b) => ({ + name: b.message, + timestamp: b.timestamp, + attributes: { + 'breadcrumb.type': b.type, + ...(b.category ? { 'breadcrumb.category': b.category } : {}), + ...(b.level ? { 'breadcrumb.level': b.level } : {}), + ...Object.fromEntries( + Object.entries(b.data ?? {}).map(([k, v]) => [`data.${k}`, String(v)]) + ), + }, + })); +} + /** * SvelteKit `handle` hook — creates a request span and propagates trace context. * @@ -82,6 +99,13 @@ export function logtideHandle(options: ClientOptions) { const method = event.request.method; const pathname = event.url.pathname; + // Capture extra request metadata + const userAgent = event.request.headers.get('user-agent'); + const forwardedFor = event.request.headers.get('x-forwarded-for'); + const queryString = event.url.search; + + const startTime = Date.now(); + const span = client.startSpan({ name: `${method} ${pathname}`, traceId, @@ -90,6 +114,9 @@ export function logtideHandle(options: ClientOptions) { 'http.method': method, 'http.url': event.url.href, 'http.target': pathname, + ...(userAgent ? { 'http.user_agent': userAgent } : {}), + ...(forwardedFor ? { 'net.peer.ip': forwardedFor } : {}), + ...(queryString ? { 'http.query_string': queryString } : {}), }, }); @@ -104,19 +131,55 @@ export function logtideHandle(options: ClientOptions) { category: 'request', message: `${method} ${pathname}`, timestamp: Date.now(), + data: { method, url: event.url.href, ...(userAgent ? { userAgent } : {}) }, }); try { const response = await resolve(event); - client.finishSpan(span.spanId, response.status >= 500 ? 'error' : 'ok'); + const durationMs = Date.now() - startTime; + + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${response.status} request`, + level: response.status >= 500 ? 'error' : response.status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status: response.status, duration_ms: durationMs }, + }); + + client.finishSpan(span.spanId, response.status >= 500 ? 'error' : 'ok', { + extraAttributes: { + 'http.status_code': response.status, + 'duration_ms': durationMs, + }, + events: breadcrumbsToEvents(scope), + }); // Inject traceparent into response const newResponse = new Response(response.body, response); newResponse.headers.set('traceparent', createTraceparent(traceId, span.spanId, true)); return newResponse; } catch (error) { - client.finishSpan(span.spanId, 'error'); + const durationMs = Date.now() - startTime; + + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `500 request`, + level: 'error', + timestamp: Date.now(), + data: { status: 500, duration_ms: durationMs }, + }); + + client.finishSpan(span.spanId, 'error', { + extraAttributes: { + 'http.status_code': 500, + 'duration_ms': durationMs, + }, + events: breadcrumbsToEvents(scope), + }); + client.captureError(error, {}, scope); throw error; } @@ -136,7 +199,7 @@ export function logtideHandleError() { : undefined; client.captureError(error, { - 'http.status': status, + 'http.status_code': status, 'error.message': message, 'http.url': event?.url?.href, }, scope); diff --git a/packages/sveltekit/tests/hooks.test.ts b/packages/sveltekit/tests/hooks.test.ts index d63bdd0..35f4025 100644 --- a/packages/sveltekit/tests/hooks.test.ts +++ b/packages/sveltekit/tests/hooks.test.ts @@ -156,6 +156,70 @@ describe('@logtide/sveltekit', () => { expect(locals.__logtideScope).toBeDefined(); expect(locals.__logtideSpanId).toBeDefined(); }); + + it('should record http.status_code and duration_ms in span attributes', async () => { + const handle = logtideHandle({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'sveltekit-test', + transport, + }); + + const event = { + request: new Request('http://localhost/api/data'), + url: new URL('http://localhost/api/data'), + locals: {} as Record, + }; + + const resolve = vi.fn().mockResolvedValue(new Response('ok', { status: 200 })); + await handle({ event, resolve }); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].attributes?.['http.status_code']).toBe(200); + expect(typeof transport.spans[0].attributes?.['duration_ms']).toBe('number'); + }); + + it('should capture user-agent in span attributes when provided', async () => { + const handle = logtideHandle({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'sveltekit-test', + transport, + }); + + const event = { + request: new Request('http://localhost/api/ua', { + headers: { 'user-agent': 'SvelteAgent/2.0' }, + }), + url: new URL('http://localhost/api/ua'), + locals: {} as Record, + }; + + const resolve = vi.fn().mockResolvedValue(new Response('ok')); + await handle({ event, resolve }); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].attributes?.['http.user_agent']).toBe('SvelteAgent/2.0'); + }); + + it('should attach breadcrumb events to span', async () => { + const handle = logtideHandle({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'sveltekit-test', + transport, + }); + + const event = { + request: new Request('http://localhost/api/events'), + url: new URL('http://localhost/api/events'), + locals: {} as Record, + }; + + const resolve = vi.fn().mockResolvedValue(new Response('ok')); + await handle({ event, resolve }); + + expect(transport.spans).toHaveLength(1); + expect(transport.spans[0].events).toBeDefined(); + expect(transport.spans[0].events!.length).toBeGreaterThanOrEqual(2); + }); }); describe('logtideHandleError', () => { @@ -178,6 +242,26 @@ describe('@logtide/sveltekit', () => { expect(transport.logs).toHaveLength(1); expect(transport.logs[0].level).toBe('error'); }); + + it('should use http.status_code (not http.status) in error metadata', () => { + logtideHandle({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'sveltekit-test', + transport, + }); + + const handleError = logtideHandleError(); + + handleError({ + error: new Error('not found'), + status: 404, + message: 'Not Found', + }); + + expect(transport.logs).toHaveLength(1); + expect(transport.logs[0].metadata?.['http.status_code']).toBe(404); + expect(transport.logs[0].metadata?.['http.status']).toBeUndefined(); + }); }); describe('logtideHandleFetch', () => { diff --git a/packages/types/package.json b/packages/types/package.json index 64ea3da..6a0a897 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/types", - "version": "0.5.6", + "version": "0.6.0", "description": "Shared type definitions for the LogTide SDK ecosystem", "type": "module", "main": "./dist/index.cjs",