From 4167822d5180aea2dbabc82ba642a23a20e68af5 Mon Sep 17 00:00:00 2001 From: pileskyd Date: Tue, 18 Aug 2026 18:41:56 +1000 Subject: [PATCH] feat(elysia): add isolated Elysia 2 integration --- CHANGELOG.md | 6 + README.md | 4 +- package.json | 4 +- packages/elysia/README.md | 45 +- packages/elysia/package.json | 20 +- .../scripts/patch-next-declarations.mjs | 14 + packages/elysia/src/next.ts | 1 + packages/elysia/src/plugin-next.ts | 218 +++++++++ packages/elysia/tests/plugin-next.test.ts | 434 ++++++++++++++++++ packages/elysia/tsup.config.ts | 13 +- pnpm-lock.yaml | 124 ++++- pnpm-workspace.yaml | 1 + test-apps/elysia-v2/package.json | 21 + test-apps/elysia-v2/src/app.ts | 23 + test-apps/elysia-v2/tests/smoke.test.ts | 58 +++ test-apps/elysia-v2/tsconfig.json | 8 + test-apps/elysia-v2/vitest.config.ts | 13 + 17 files changed, 971 insertions(+), 36 deletions(-) create mode 100644 packages/elysia/scripts/patch-next-declarations.mjs create mode 100644 packages/elysia/src/next.ts create mode 100644 packages/elysia/src/plugin-next.ts create mode 100644 packages/elysia/tests/plugin-next.test.ts create mode 100644 test-apps/elysia-v2/package.json create mode 100644 test-apps/elysia-v2/src/app.ts create mode 100644 test-apps/elysia-v2/tests/smoke.test.ts create mode 100644 test-apps/elysia-v2/tsconfig.json create mode 100644 test-apps/elysia-v2/vitest.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a2685..c356c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## [Unreleased] + +### Added + +- **`@logtide/elysia/next`** — an isolated Elysia 2.x integration using the new `request`, `afterHandle`, and `error` lifecycle contracts. The existing `@logtide/elysia` export remains on the Elysia 1.4 contract. + ## [0.12.0] - 2026-08-06 ### Added diff --git a/README.md b/README.md index cb1db06..260b5a2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ | [`@logtide/sveltekit`](./packages/sveltekit) | [![npm](https://img.shields.io/npm/v/@logtide/sveltekit?color=blue)](https://www.npmjs.com/package/@logtide/sveltekit) | SvelteKit hooks integration | | [`@logtide/hono`](./packages/hono) | [![npm](https://img.shields.io/npm/v/@logtide/hono?color=blue)](https://www.npmjs.com/package/@logtide/hono) | Hono middleware | | [`@logtide/angular`](./packages/angular) | [![npm](https://img.shields.io/npm/v/@logtide/angular?color=blue)](https://www.npmjs.com/package/@logtide/angular) | Angular ErrorHandler, HTTP Interceptor | -| [`@logtide/elysia`](./packages/elysia) | [![npm](https://img.shields.io/npm/v/@logtide/elysia?color=blue)](https://www.npmjs.com/package/@logtide/elysia) | Elysia plugin | +| [`@logtide/elysia`](./packages/elysia) | [![npm](https://img.shields.io/npm/v/@logtide/elysia?color=blue)](https://www.npmjs.com/package/@logtide/elysia) | Elysia 1.4 plugin + Elysia 2 preview via `/next` | | [`@logtide/browser`](./packages/browser) | [![npm](https://img.shields.io/npm/v/@logtide/browser?color=blue)](https://www.npmjs.com/package/@logtide/browser) | Browser SDK — Web Vitals, breadcrumbs, offline transport | | [`@logtide/cli`](./packages/cli) | [![npm](https://img.shields.io/npm/v/@logtide/cli?color=blue)](https://www.npmjs.com/package/@logtide/cli) | CLI — source maps upload and release management | | [`@logtide/sdk-node`](./packages/node) | [![npm](https://img.shields.io/npm/v/@logtide/sdk-node?color=blue)](https://www.npmjs.com/package/@logtide/sdk-node) | Legacy Node.js SDK _(use `@logtide/express` or `@logtide/fastify` instead)_ | @@ -101,7 +101,7 @@ npx @logtide/cli sourcemaps upload --api-url https://your-logtide-instance.com - ├── @logtide/sveltekit ← SvelteKit (handle/handleError/handleFetch) ├── @logtide/hono ← Hono middleware ├── @logtide/angular ← Angular (ErrorHandler + HttpInterceptor) -├── @logtide/elysia ← Elysia plugin +├── @logtide/elysia ← Elysia 1.4 plugin + Elysia 2 /next export ├── @logtide/browser ← Browser SDK (Web Vitals, breadcrumbs, offline) ├── @logtide/cli ← CLI (source maps upload) └── @logtide/sdk-node ← Legacy standalone Node.js SDK diff --git a/package.json b/package.json index 4970ce3..725ffa1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "pnpm -r --filter @logtide/* build", "test": "pnpm -r --filter @logtide/* test", - "test:smoke": "pnpm -r --filter test-app-hono --filter test-app-elysia --filter test-app-node-express --filter test-app-node-fastify test", + "test:smoke": "pnpm -r --filter test-app-hono --filter test-app-elysia --filter test-app-elysia-v2 --filter test-app-node-express --filter test-app-node-fastify test", "test:e2e": "pnpm -r --filter test-app-nextjs --filter test-app-sveltekit --filter test-app-nuxt --filter test-app-angular test", "test:all": "pnpm test && pnpm test:smoke", "typecheck": "pnpm -r typecheck", @@ -34,7 +34,7 @@ "simple-git": ">=3.32.3", "@hono/node-server": ">=1.19.10", "defu": ">=6.1.5", - "elysia": ">=1.4.27", + "elysia@<1.4.27": "1.4.29", "h3": ">=1.15.9", "immutable": ">=5.1.5", "lodash": ">=4.18.1", diff --git a/packages/elysia/README.md b/packages/elysia/README.md index 0e45fab..183e0ac 100644 --- a/packages/elysia/README.md +++ b/packages/elysia/README.md @@ -7,7 +7,7 @@

npm License - Elysia + Elysia Release

@@ -19,8 +19,8 @@ ## Features -- **Automatic request spans** via `onRequest` / `onAfterHandle` lifecycle hooks -- **Error capture** via `onError` hook with full request context +- **Automatic request spans** via version-specific Elysia lifecycle hooks +- **Error capture** with full request context - **W3C Trace Context** propagation (`traceparent` in/out) - **Breadcrumbs** for HTTP requests - **Global plugin** — traces all routes automatically @@ -41,6 +41,8 @@ bun add @logtide/elysia ## Quick Start +Use the root export with Elysia 1.4: + ```typescript import { Elysia } from 'elysia'; import { logtide } from '@logtide/elysia'; @@ -58,17 +60,39 @@ const app = new Elysia() .listen(3000); ``` +Use the isolated `next` export with Elysia 2.x: + +```typescript +import { Elysia } from 'elysia'; +import { logtide } from '@logtide/elysia/next'; + +const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_your_key@your-instance.com', + service: 'my-elysia-api', + environment: 'production', + })) + .get('/hello', () => 'Hello World') + .listen(3000); +``` + +Do not mix the root and `next` exports: they intentionally target the incompatible Elysia 1.4 and 2.x contracts respectively. + --- ## How It Works -The plugin registers global lifecycle hooks on the Elysia app: +Both plugins register global lifecycle hooks on the Elysia app: -1. **`onRequest`** — extracts incoming `traceparent`, creates a span, stores scope on a WeakMap keyed by the request -2. **`onAfterHandle`** — finishes the span with `ok` or `error`, injects `traceparent` into response headers -3. **`onError`** — finishes the span as `error`, captures the error with full HTTP context +| Stage | Elysia 1.4 (`@logtide/elysia`) | Elysia 2 (`@logtide/elysia/next`) | +|-------|---------------------------------|-------------------------------------| +| Request | `onRequest` | `request` | +| Response | `onAfterHandle` | `afterHandle` + `afterResponse` | +| Error | `onError` | `error` | -The plugin is registered with `.as('global')` so it applies to all routes, including those added after the plugin. +The request hook extracts an incoming `traceparent` and creates a span. The response hooks inject `traceparent` and finish the span using the final mapped status. The error hook marks the span as failed and captures the exception with HTTP context. + +Each plugin is registered with `.as('global')` so it applies to all routes, including those added after the plugin. --- @@ -126,8 +150,13 @@ Outgoing responses include a `traceparent` header with the span's context. ## Exports ```typescript +// Elysia 1.4 import { logtide } from '@logtide/elysia'; import type { LogtideElysiaOptions } from '@logtide/elysia'; + +// Elysia 2.x +import { logtide } from '@logtide/elysia/next'; +import type { LogtideElysiaOptions } from '@logtide/elysia/next'; ``` --- diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 663a170..c2becec 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -16,13 +16,23 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./next": { + "import": { + "types": "./dist/next.d.ts", + "default": "./dist/next.js" + }, + "require": { + "types": "./dist/next.d.cts", + "default": "./dist/next.cjs" + } } }, "files": [ "dist" ], "scripts": { - "build": "tsup", + "build": "tsup && node scripts/patch-next-declarations.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"" @@ -46,13 +56,15 @@ "@logtide/types": "workspace:*" }, "peerDependencies": { - "elysia": ">=1.0.0" + "elysia": ">=1.4.0 <2.0.0 || >=2.0.0-beta.4 <3.0.0" }, "devDependencies": { "@sinclair/typebox": "^0.34.48", - "elysia": "^1.2.0", + "elysia": "npm:elysia@latest", + "elysia-next": "npm:elysia@next", + "exact-mirror": "^1.2.4", "file-type": "^22.0.0", "tsup": "^8.5.1", - "typescript": "^5.5.4" + "typescript": "^5.7.0" } } diff --git a/packages/elysia/scripts/patch-next-declarations.mjs b/packages/elysia/scripts/patch-next-declarations.mjs new file mode 100644 index 0000000..78f2804 --- /dev/null +++ b/packages/elysia/scripts/patch-next-declarations.mjs @@ -0,0 +1,14 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +await Promise.all( + ['dist/next.d.ts', 'dist/next.d.cts'].map(async (path) => { + const declaration = await readFile(path, 'utf8'); + const patched = declaration.replaceAll('elysia-next', 'elysia'); + + if (patched === declaration) { + throw new Error(`Expected ${path} to reference the elysia-next development alias`); + } + + await writeFile(path, patched); + }) +); diff --git a/packages/elysia/src/next.ts b/packages/elysia/src/next.ts new file mode 100644 index 0000000..728ffef --- /dev/null +++ b/packages/elysia/src/next.ts @@ -0,0 +1 @@ +export { logtide, type LogtideElysiaOptions } from './plugin-next'; diff --git a/packages/elysia/src/plugin-next.ts b/packages/elysia/src/plugin-next.ts new file mode 100644 index 0000000..0e7c5b4 --- /dev/null +++ b/packages/elysia/src/plugin-next.ts @@ -0,0 +1,218 @@ +import type { ClientOptions } from '@logtide/types'; +import type { Scope, SpanEvent } from '@logtide/core'; +import { + hub, + ConsoleIntegration, + GlobalErrorIntegration, + generateTraceId, + parseTraceparent, + createTraceparent, +} from '@logtide/core'; +import Elysia, { ElysiaStatus, StatusMap } from 'elysia-next'; + +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)]) + ), + }, + })); +} + +function statusFrom(value: unknown): number | undefined { + if (typeof value === 'number') return value; + + if (typeof value === 'string' && value in StatusMap) { + return StatusMap[value as keyof typeof StatusMap]; + } + + if (value instanceof Response || value instanceof ElysiaStatus) { + return value.status; + } + + return undefined; +} + +/** + * Elysia 2 plugin for LogTide — request tracing, error capture, breadcrumbs. + * + * @example + * ```ts + * import { Elysia } from 'elysia'; + * import { logtide } from '@logtide/elysia/next'; + * + * const app = new Elysia() + * .use(logtide({ dsn: '...', service: 'my-api' })) + * .get('/', () => 'Hello'); + * ``` + */ +export function logtide(options: LogtideElysiaOptions) { + hub.init({ + service: 'elysia', + ...options, + integrations: [ + new ConsoleIntegration(), + new GlobalErrorIntegration(), + ...(options.integrations ?? []), + ], + }); + + const spanMap = new WeakMap(); + + return new Elysia({ name: '@logtide/elysia/next' }) + .request(({ request }) => { + const client = hub.getClient(); + if (!client) return; + + // Extract trace context + const traceparent = request.headers.get('traceparent'); + let traceId: string; + let parentSpanId: string | undefined; + + if (traceparent) { + const ctx = parseTraceparent(traceparent); + if (ctx) { + traceId = ctx.traceId; + parentSpanId = ctx.parentSpanId; + } else { + traceId = generateTraceId(); + } + } else { + traceId = generateTraceId(); + } + + 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} ${pathname}`, + traceId, + parentSpanId, + attributes: startAttributes, + }); + + scope.spanId = span.spanId; + + // Capture startTime BEFORE adding the breadcrumb + const startTime = Date.now(); + + scope.addBreadcrumb({ + type: 'http', + category: 'request', + message: `${method} ${pathname}`, + timestamp: Date.now(), + data: { + method, + url: request.url, + ...(userAgent ? { userAgent } : {}), + }, + }); + + spanMap.set(request, { + spanId: span.spanId, + scope, + traceId, + startTime, + finalized: false, + errored: false, + }); + }) + .afterHandle(({ request, set }) => { + const ctx = spanMap.get(request); + if (!ctx) return; + + // This must happen before response mapping; afterResponse is too late to mutate headers. + set.headers.traceparent = createTraceparent(ctx.traceId, ctx.spanId, true); + }) + .error(({ request, error, set }) => { + const client = hub.getClient(); + const ctx = spanMap.get(request); + if (!client) return; + + if (ctx) { + ctx.errored = true; + set.headers.traceparent = createTraceparent(ctx.traceId, ctx.spanId, true); + + client.captureError(error, { + 'http.url': request.url, + 'http.method': request.method, + }, ctx.scope); + } else { + client.captureError(error, { + 'http.url': request.url, + 'http.method': request.method, + }); + } + }) + .afterResponse(({ request, responseValue, set }) => { + const client = hub.getClient(); + const ctx = spanMap.get(request); + if (!client || !ctx || ctx.finalized) return; + + const status = statusFrom(set.status) ?? statusFrom(responseValue) ?? 200; + const { scope, spanId, startTime } = ctx; + const url = new URL(request.url); + const pathname = url.pathname; + const method = request.method; + const durationMs = Date.now() - startTime; + + scope.addBreadcrumb({ + type: 'http', + category: 'response', + message: `${status} ${method} ${pathname}`, + level: ctx.errored || status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info', + timestamp: Date.now(), + data: { status, duration_ms: durationMs }, + }); + + const events: SpanEvent[] = breadcrumbsToEvents(scope); + const spanStatus = ctx.errored || status >= 500 ? 'error' : 'ok'; + + client.finishSpan(spanId, spanStatus, { + extraAttributes: { + 'http.status_code': status, + 'duration_ms': durationMs, + }, + events, + }); + ctx.finalized = true; + }) + .as('global'); +} diff --git a/packages/elysia/tests/plugin-next.test.ts b/packages/elysia/tests/plugin-next.test.ts new file mode 100644 index 0000000..8eb75bd --- /dev/null +++ b/packages/elysia/tests/plugin-next.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Elysia, { NotFound, status } from 'elysia-next'; +import { logtide } from '../src/plugin-next'; +import type { InternalLogEntry, Span } from '@logtide/types'; + +function createMockTransport() { + return { + logs: [] as InternalLogEntry[], + spans: [] as Span[], + async sendLogs(logs: InternalLogEntry[]) { this.logs.push(...logs); }, + async sendSpans(spans: Span[]) { this.spans.push(...spans); }, + async flush() {}, + }; +} + +describe('@logtide/elysia/next plugin', () => { + let transport: ReturnType; + let hub: typeof import('@logtide/core').hub; + + beforeEach(async () => { + const core = await import('@logtide/core'); + hub = core.hub; + await hub.close(); + transport = createMockTransport(); + }); + + afterEach(async () => { + await hub.close(); + }); + + it('should create spans for requests', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/hello', () => 'world'); + + const res = await app.handle(new Request('http://localhost/hello')); + expect(res.status).toBe(200); + expect(await res.text()).toBe('world'); + + // Elysia should have recorded a span + expect(transport.spans.length).toBeGreaterThanOrEqual(1); + const span = transport.spans.find(s => s.name.includes('/hello')); + expect(span).toBeDefined(); + expect(span!.status).toBe('ok'); + }); + + it('should extract incoming traceparent', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/traced', () => 'ok'); + + const traceId = '4bf92f3577b34da6a3ce929d0e0e4736'; + const res = await app.handle(new Request('http://localhost/traced', { + headers: { + traceparent: `00-${traceId}-00f067aa0ba902b7-01`, + }, + })); + + expect(res.status).toBe(200); + const span = transport.spans.find(s => s.name.includes('/traced')); + expect(span?.traceId).toBe(traceId); + }); + + it('should capture errors from handlers', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/error', () => { + throw new Error('elysia boom'); + }); + + const res = await app.handle(new Request('http://localhost/error')); + // Elysia returns 500 for errors + expect(res.status).toBe(500); + + // Should have captured the error + expect(transport.logs.length).toBeGreaterThanOrEqual(1); + 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 read status() response wrappers from the Elysia 2 contract', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/status-wrapper', () => status(404, 'missing')); + + const response = await app.handle(new Request('http://localhost/status-wrapper')); + + expect(response.status).toBe(404); + const span = transport.spans.find(s => s.name.includes('/status-wrapper')); + expect(span?.attributes['http.status_code']).toBe(404); + }); + + it('should read status from a Web Response', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/web-response', () => new Response('accepted', { status: 202 })); + + const response = await app.handle(new Request('http://localhost/web-response')); + + expect(response.status).toBe(202); + const span = transport.spans.find(s => s.name.includes('/web-response')); + expect(span?.attributes['http.status_code']).toBe(202); + }); + + it('should not treat ordinary payload status fields as HTTP statuses', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/status-payload', () => ({ status: 503, healthy: true })); + + const response = await app.handle(new Request('http://localhost/status-payload')); + + expect(response.status).toBe(200); + const span = transport.spans.find(s => s.name.includes('/status-payload')); + expect(span?.attributes['http.status_code']).toBe(200); + expect(span?.status).toBe('ok'); + }); + + it('should let set.status override a default Web Response status', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/response-override', ({ set }) => { + set.status = 'Created'; + return new Response('created'); + }); + + const response = await app.handle(new Request('http://localhost/response-override')); + + expect(response.status).toBe(201); + const span = transport.spans.find(s => s.name.includes('/response-override')); + expect(span?.attributes['http.status_code']).toBe(201); + }); + + it('should finalize from the response produced by later afterHandle hooks', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .afterHandle(() => status(418, 'teapot')) + .get('/later-after-handle', () => 'ok'); + + const response = await app.handle(new Request('http://localhost/later-after-handle')); + + expect(response.status).toBe(418); + const span = transport.spans.find(s => s.name.includes('/later-after-handle')); + expect(span?.attributes['http.status_code']).toBe(418); + }); + + it('should normalize named set.status values', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/named-status', ({ set }) => { + set.status = 'Created'; + return 'created'; + }); + + const response = await app.handle(new Request('http://localhost/named-status')); + + expect(response.status).toBe(201); + const span = transport.spans.find(s => s.name.includes('/named-status')); + expect(span?.attributes['http.status_code']).toBe(201); + }); + + it('should use the status carried by Elysia 2 errors', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/not-found-error', () => { + throw new NotFound(); + }); + + const response = await app.handle(new Request('http://localhost/not-found-error')); + + expect(response.status).toBe(404); + expect(response.headers.get('traceparent')).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/ + ); + const spans = transport.spans.filter(s => s.name.includes('/not-found-error')); + expect(spans).toHaveLength(1); + expect(spans[0].attributes['http.status_code']).toBe(404); + expect(spans[0].status).toBe('error'); + }); + + it('should capture errors thrown by later afterHandle hooks', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .afterHandle(() => { + throw new Error('late failure'); + }) + .get('/late-error', () => 'ok'); + + const response = await app.handle(new Request('http://localhost/late-error')); + + expect(response.status).toBe(500); + expect(response.headers.get('traceparent')).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/ + ); + const spans = transport.spans.filter(s => s.name.includes('/late-error')); + expect(spans).toHaveLength(1); + expect(spans[0].attributes['http.status_code']).toBe(500); + expect(spans[0].status).toBe('error'); + }); + + it('should keep error spans when a later error hook maps the response', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .error(() => status(418, 'handled')) + .get('/mapped-error', () => { + throw new Error('mapped failure'); + }); + + const response = await app.handle(new Request('http://localhost/mapped-error')); + + expect(response.status).toBe(418); + expect(response.headers.get('traceparent')).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/ + ); + const spans = transport.spans.filter(s => s.name.includes('/mapped-error')); + expect(spans).toHaveLength(1); + expect(spans[0].attributes['http.status_code']).toBe(418); + expect(spans[0].status).toBe('error'); + }); + + it('should inject traceparent into the response', async () => { + const app = new Elysia() + .use(logtide({ + dsn: 'https://lp_key@api.logtide.dev/proj', + service: 'elysia-test', + transport, + })) + .get('/response-trace', () => 'ok'); + + const response = await app.handle(new Request('http://localhost/response-trace')); + + expect(response.headers.get('traceparent')).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/ + ); + }); + + 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/elysia/tsup.config.ts b/packages/elysia/tsup.config.ts index 7c09de8..d9f1a0e 100644 --- a/packages/elysia/tsup.config.ts +++ b/packages/elysia/tsup.config.ts @@ -1,10 +1,21 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/next.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true, external: ['@logtide/core', '@logtide/types', 'elysia'], + esbuildPlugins: [ + { + name: 'elysia-next-runtime-alias', + setup(build) { + build.onResolve({ filter: /^elysia-next$/ }, () => ({ + path: 'elysia', + external: true, + })); + }, + }, + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1691989..f0d8f0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ overrides: simple-git: '>=3.32.3' '@hono/node-server': '>=1.19.10' defu: '>=6.1.5' - elysia: '>=1.4.27' + elysia@<1.4.27: 1.4.29 h3: '>=1.15.9' immutable: '>=5.1.5' lodash: '>=4.18.1' @@ -154,17 +154,23 @@ importers: specifier: ^0.34.48 version: 0.34.48 elysia: - specifier: '>=1.4.27' - version: 1.4.28(@sinclair/typebox@0.34.48)(exact-mirror@0.2.6(@sinclair/typebox@0.34.48))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3) + specifier: npm:elysia@latest + version: 1.4.29(@sinclair/typebox@0.34.48)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.8.3) + elysia-next: + specifier: npm:elysia@next + version: elysia@2.0.0-beta.4(exact-mirror@1.2.4(typebox@1.3.15))(openapi-types@12.1.3)(typebox@1.3.15)(typescript@5.8.3) + exact-mirror: + specifier: ^1.2.4 + version: 1.2.4(typebox@1.3.15) file-type: specifier: '>=21.3.2' version: 22.0.0 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.3) typescript: - specifier: ^5.5.4 - version: 5.9.3 + specifier: ^5.7.0 + version: 5.8.3 packages/express: dependencies: @@ -395,8 +401,8 @@ importers: specifier: workspace:* version: link:../../packages/elysia elysia: - specifier: '>=1.4.27' - version: 1.4.28(@sinclair/typebox@0.34.48)(exact-mirror@0.2.6(@sinclair/typebox@0.34.48))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3) + specifier: 1.4.29 + version: 1.4.29(@sinclair/typebox@0.34.48)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3) devDependencies: logtide-mock-server: specifier: workspace:* @@ -408,6 +414,31 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@20.19.32)(jiti@2.6.1)(jsdom@28.1.0)(less@4.2.2)(lightningcss@1.32.0)(sass@1.85.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + test-apps/elysia-v2: + dependencies: + '@logtide/core': + specifier: workspace:* + version: link:../../packages/core + '@logtide/elysia': + specifier: workspace:* + version: link:../../packages/elysia + elysia: + specifier: npm:elysia@next + version: 2.0.0-beta.4(exact-mirror@1.2.4(typebox@1.3.15))(openapi-types@12.1.3)(typebox@1.3.15)(typescript@5.8.3) + exact-mirror: + specifier: ^1.2.4 + version: 1.2.4(typebox@1.3.15) + devDependencies: + logtide-mock-server: + specifier: workspace:* + version: link:../mock-server + typescript: + specifier: ^5.7.0 + version: 5.8.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@20.19.32)(jiti@2.6.1)(jsdom@28.1.0)(less@4.2.2)(lightningcss@1.32.0)(sass@1.85.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + test-apps/hono: dependencies: '@hono/node-server': @@ -4327,6 +4358,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + deuri@3.0.0: + resolution: {integrity: sha512-3rbQr245VesQGHyzUlWmpv3WihImbLT5NBe/mrgsgEvc7aWTYEpDhER8Z3RsdiDSYw1kxQo0kU9fpociUu77/w==} + devalue@5.7.0: resolution: {integrity: sha512-qCvc8m7cImp1QDCsiY+C2EdSBWSj7Ucfoq87scSdYboDiIKdvMtFbH1U2VReBls6WMhMaUOoK3ZJEDNG/7zm3w==} @@ -4379,8 +4413,8 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - elysia@1.4.28: - resolution: {integrity: sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg==} + elysia@1.4.29: + resolution: {integrity: sha512-GwMRGGwSdjfPt+w3LA0fqTuYJtS8uVRJicvoar98/HrO5qdFKDc9CwjIb6Kja+v39lkY+58hr2JvdR9jQzlUuA==} peerDependencies: '@sinclair/typebox': '>= 0.34.0 < 1' '@types/bun': '>= 1.2.0' @@ -4394,6 +4428,22 @@ packages: typescript: optional: true + elysia@2.0.0-beta.4: + resolution: {integrity: sha512-pFjjP+uANO9kMLvN3QKQphCH5atwOTCC/lMhvj+fDhV3BWiwGTDNDyZPMpu0nmtcUIgjrlVyDajhuzMkYoBUGg==} + peerDependencies: + '@types/bun': '>= 1.3.0' + exact-mirror: '>= 1.2.2' + openapi-types: '>= 12.0.0' + typebox: '>= 1.3.0' + typescript: '>= 5.7.0' + peerDependenciesMeta: + '@types/bun': + optional: true + openapi-types: + optional: true + typescript: + optional: true + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4546,12 +4596,12 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - exact-mirror@0.2.6: - resolution: {integrity: sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA==} + exact-mirror@1.2.4: + resolution: {integrity: sha512-lNLw824fkSqcHSxNIh8skA7gHO5X5Q8UUllXuRedtJCU8cJwKOT3XPQY8GfhveatFMO4nCK8+oEXsLGErMWC/Q==} peerDependencies: - '@sinclair/typebox': ^0.34.15 + typebox: '>= 1.1.0' peerDependenciesMeta: - '@sinclair/typebox': + typebox: optional: true execa@8.0.1: @@ -5486,6 +5536,9 @@ packages: memoirist@0.4.0: resolution: {integrity: sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==} + memoirist@1.2.2: + resolution: {integrity: sha512-d5b1l7vth7Iim1z4z+GHQtxs2sQvcsvDn928FdJW7dPFu3ChIb66xpaA/QF1VKKEiskScuB6RXramHpXF35ZEw==} + merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} @@ -7161,6 +7214,9 @@ packages: type-level-regexp@0.1.17: resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + typebox@1.3.15: + resolution: {integrity: sha512-gOKAjLqUr+bFGbO5vxCEO5+nHh2wO+swzSIkiJJC0kJ4oLN6f65SrZn6tJYhsYO+qdnpf2NmlQwgsayYoi1NkQ==} + typed-assert@1.0.9: resolution: {integrity: sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==} @@ -7253,6 +7309,7 @@ packages: unplugin-vue-router@0.19.2: resolution: {integrity: sha512-u5dgLBarxE5cyDK/hzJGfpCTLIAyiTXGlo85COuD4Nssj6G7NxS+i9mhCWz/1p/ud1eMwdcUbTXehQe41jYZUA==} + deprecated: 'Merged into vuejs/router. Migrate: https://router.vuejs.org/guide/migration/v4-to-v5.html' peerDependencies: '@vue/compiler-sfc': ^3.5.17 vue-router: ^4.6.0 @@ -7355,6 +7412,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-license@3.0.4: @@ -11846,6 +11904,8 @@ snapshots: detect-node@2.1.0: {} + deuri@3.0.0: {} + devalue@5.7.0: {} diff@8.0.3: {} @@ -11894,11 +11954,23 @@ snapshots: electron-to-chromium@1.5.286: {} - elysia@1.4.28(@sinclair/typebox@0.34.48)(exact-mirror@0.2.6(@sinclair/typebox@0.34.48))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3): + elysia@1.4.29(@sinclair/typebox@0.34.48)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.8.3): dependencies: '@sinclair/typebox': 0.34.48 cookie: 1.1.1 - exact-mirror: 0.2.6(@sinclair/typebox@0.34.48) + exact-mirror: 1.2.4(typebox@1.3.15) + fast-decode-uri-component: 1.0.1 + file-type: 22.0.0 + memoirist: 0.4.0 + openapi-types: 12.1.3 + optionalDependencies: + typescript: 5.8.3 + + elysia@1.4.29(@sinclair/typebox@0.34.48)(exact-mirror@1.2.4(typebox@1.3.15))(file-type@22.0.0)(openapi-types@12.1.3)(typescript@5.9.3): + dependencies: + '@sinclair/typebox': 0.34.48 + cookie: 1.1.1 + exact-mirror: 1.2.4(typebox@1.3.15) fast-decode-uri-component: 1.0.1 file-type: 22.0.0 memoirist: 0.4.0 @@ -11906,6 +11978,16 @@ snapshots: optionalDependencies: typescript: 5.9.3 + elysia@2.0.0-beta.4(exact-mirror@1.2.4(typebox@1.3.15))(openapi-types@12.1.3)(typebox@1.3.15)(typescript@5.8.3): + dependencies: + deuri: 3.0.0 + exact-mirror: 1.2.4(typebox@1.3.15) + memoirist: 1.2.2 + typebox: 1.3.15 + optionalDependencies: + openapi-types: 12.1.3 + typescript: 5.8.3 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -12043,9 +12125,9 @@ snapshots: events@3.3.0: {} - exact-mirror@0.2.6(@sinclair/typebox@0.34.48): + exact-mirror@1.2.4(typebox@1.3.15): optionalDependencies: - '@sinclair/typebox': 0.34.48 + typebox: 1.3.15 execa@8.0.1: dependencies: @@ -12070,7 +12152,7 @@ snapshots: body-parser: 1.20.4 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.7.2 + cookie: 1.1.1 cookie-signature: 1.0.7 debug: 2.6.9 depd: 2.0.0 @@ -13099,6 +13181,8 @@ snapshots: memoirist@0.4.0: {} + memoirist@1.2.2: {} + merge-descriptors@1.0.3: {} merge-descriptors@2.0.0: {} @@ -15237,6 +15321,8 @@ snapshots: type-level-regexp@0.1.17: {} + typebox@1.3.15: {} + typed-assert@1.0.9: {} typescript@5.8.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 38d7f30..a0c624e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - "test-apps/node-fastify" - "test-apps/hono" - "test-apps/elysia" + - "test-apps/elysia-v2" - "test-apps/nextjs" - "test-apps/nuxt" - "test-apps/sveltekit" diff --git a/test-apps/elysia-v2/package.json b/test-apps/elysia-v2/package.json new file mode 100644 index 0000000..36aeeee --- /dev/null +++ b/test-apps/elysia-v2/package.json @@ -0,0 +1,21 @@ +{ + "name": "test-app-elysia-v2", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@logtide/elysia": "workspace:*", + "@logtide/core": "workspace:*", + "elysia": "npm:elysia@next", + "exact-mirror": "^1.2.4" + }, + "devDependencies": { + "logtide-mock-server": "workspace:*", + "vitest": "^3.0.0", + "typescript": "^5.7.0" + } +} diff --git a/test-apps/elysia-v2/src/app.ts b/test-apps/elysia-v2/src/app.ts new file mode 100644 index 0000000..918f3f1 --- /dev/null +++ b/test-apps/elysia-v2/src/app.ts @@ -0,0 +1,23 @@ +import { Elysia } from 'elysia'; +import { logtide } from '@logtide/elysia/next'; +import { hub } from '@logtide/core'; + +export function createApp(dsn: string) { + return new Elysia() + .use(logtide({ + dsn, + service: 'test-elysia-v2', + environment: 'test', + batchSize: 1, + flushInterval: 500, + })) + .get('/test-log', () => { + const client = hub.getClient(); + client?.captureLog('info', 'manual log from elysia v2', { route: '/test-log' }); + return { ok: true }; + }) + .get('/test-error', () => { + throw new Error('Test error from Elysia 2'); + }) + .get('/health', () => ({ status: 'ok' })); +} diff --git a/test-apps/elysia-v2/tests/smoke.test.ts b/test-apps/elysia-v2/tests/smoke.test.ts new file mode 100644 index 0000000..534368c --- /dev/null +++ b/test-apps/elysia-v2/tests/smoke.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { createMockServer, waitForLogs, waitForSpans, buildMockDSN } from 'logtide-mock-server/test-utils'; +import { hub } from '@logtide/core'; +import { createApp } from '../src/app.js'; + +describe('Elysia 2 + @logtide/elysia/next smoke test', () => { + let mockServer: ReturnType; + let mockUrl: string; + let app: ReturnType; + + beforeAll(async () => { + mockServer = createMockServer(); + const mock = await mockServer.start(); + mockUrl = mock.url; + app = createApp(buildMockDSN(mockUrl)); + }); + + afterAll(async () => { + await hub.close(); + await mockServer.stop(); + }); + + beforeEach(() => { + mockServer.reset(); + }); + + it('should send logs on incoming HTTP requests', async () => { + const response = await app.handle(new Request('http://localhost/test-log')); + expect(response.status).toBe(200); + + const logs = await waitForLogs(mockUrl, 1); + const log = logs.find((entry) => entry.message === 'manual log from elysia v2'); + expect(log).toBeDefined(); + expect(log!.service).toBe('test-elysia-v2'); + expect(log!.metadata?.route).toBe('/test-log'); + }); + + it('should capture errors with trace context', async () => { + const response = await app.handle(new Request('http://localhost/test-error')); + expect(response.status).toBe(500); + + const logs = await waitForLogs(mockUrl, 1); + const errorLog = logs.find((entry) => entry.level === 'error'); + expect(errorLog).toBeDefined(); + expect(errorLog!.service).toBe('test-elysia-v2'); + expect(errorLog!.trace_id).toMatch(/^[0-9a-f]{32}$/); + }); + + it('should send request spans', async () => { + await app.handle(new Request('http://localhost/test-log')); + + const spans = await waitForSpans(mockUrl, 1); + const span = spans.find((entry) => entry.name?.includes('GET /test-log')); + expect(span).toBeDefined(); + expect(span!.serviceName).toBe('test-elysia-v2'); + expect(span!.status?.code).toBe(1); + }); +}); diff --git a/test-apps/elysia-v2/tsconfig.json b/test-apps/elysia-v2/tsconfig.json new file mode 100644 index 0000000..efe8373 --- /dev/null +++ b/test-apps/elysia-v2/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": ["src/**/*"] +} diff --git a/test-apps/elysia-v2/vitest.config.ts b/test-apps/elysia-v2/vitest.config.ts new file mode 100644 index 0000000..5ce6cf1 --- /dev/null +++ b/test-apps/elysia-v2/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + dedupe: ['elysia'], + }, + test: { + globals: false, + environment: 'node', + include: ['tests/**/*.test.ts'], + testTimeout: 30_000, + }, +});