Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a37aeda
chore: draft
logaretm Feb 23, 2026
2f0e311
feat: draft withElysia function
logaretm Feb 23, 2026
da1f8f6
feat: enrich span attributes
logaretm Feb 24, 2026
4d822fb
fix: ignore elysia consistent exports test
logaretm Feb 26, 2026
a88bf56
refactor: remake into an SDK
logaretm Mar 5, 2026
3210c59
chore: added SDK to configurations
logaretm Mar 5, 2026
2e229f3
fix: avoid using bun namespace directly
logaretm Mar 5, 2026
0624d32
test: unit tests
logaretm Mar 5, 2026
55867aa
tests: added e2e test app
logaretm Mar 5, 2026
5948cd4
fix(elysia): set parameterized transaction name on error events
logaretm Mar 5, 2026
3b04b34
fix: remove bun's competing span and adjust tests
logaretm Mar 5, 2026
2bf81cb
tests(elysia): expand e2e test coverage
logaretm Mar 5, 2026
a3133ef
fix(elysia): align shouldHandleError default with Fastify
logaretm Mar 5, 2026
7d192e1
tests(elysia): add string error and isolation scope tests
logaretm Mar 5, 2026
aea6fca
feat(elysia): inject sentry-trace and baggage into response headers
logaretm Mar 5, 2026
c6397a8
fix: minor comments
logaretm Mar 5, 2026
933d88e
fix(elysia): use winterCGHeadersToDict for request headers
logaretm Mar 5, 2026
6c06a1b
chore: dedup yarn
logaretm Mar 5, 2026
73ff36f
fix: dep versions
logaretm Mar 5, 2026
af5e459
fix: filter spans for handlers without a name
logaretm Mar 5, 2026
fd202a0
fix(elysia): improve span filtering for function handlers
logaretm Mar 5, 2026
fedf46a
fix(elysia): drop empty spans produced by Elysia lifecycle hooks
logaretm Mar 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .craft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ targets:
- name: npm
id: '@sentry/bun'
includeNames: /^sentry-bun-\d.*\.tgz$/
- name: npm
id: '@sentry/elysia'
includeNames: /^sentry-elysia-\d.*\.tgz$/
- name: npm
id: '@sentry/hono'
includeNames: /^sentry-hono-\d.*\.tgz$/
Expand Down Expand Up @@ -194,6 +197,8 @@ targets:
onlyIfPresent: /^sentry-cloudflare-\d.*\.tgz$/
'npm:@sentry/deno':
onlyIfPresent: /^sentry-deno-\d.*\.tgz$/
'npm:@sentry/elysia':
onlyIfPresent: /^sentry-elysia-\d.*\.tgz$/
'npm:@sentry/ember':
onlyIfPresent: /^sentry-ember-\d.*\.tgz$/
'npm:@sentry/gatsby':
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ body:
- '@sentry/cloudflare'
- '@sentry/cloudflare - hono'
- '@sentry/deno'
- '@sentry/elysia'
- '@sentry/ember'
- '@sentry/gatsby'
- '@sentry/google-cloud-serverless'
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ package. Please refer to the README and instructions of those SDKs for more deta
- [`@sentry/capacitor`](https://github.com/getsentry/sentry-capacitor): SDK for Capacitor Apps and Ionic with support
for native crashes
- [`@sentry/bun`](https://github.com/getsentry/sentry-javascript/tree/master/packages/bun): SDK for Bun
- [`@sentry/elysia`](https://github.com/getsentry/sentry-javascript/tree/master/packages/elysia): SDK for Elysia
- [`@sentry/deno`](https://github.com/getsentry/sentry-javascript/tree/master/packages/deno): SDK for Deno
- [`@sentry/cloudflare`](https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare): SDK for
Cloudflare
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
2 changes: 2 additions & 0 deletions dev-packages/e2e-tests/test-applications/bun-elysia/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@sentry:registry=http://127.0.0.1:4873
@sentry-internal:registry=http://127.0.0.1:4873
25 changes: 25 additions & 0 deletions dev-packages/e2e-tests/test-applications/bun-elysia/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "bun-elysia-app",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "bun src/app.ts",
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install",
"test:assert": "pnpm test"
},
"dependencies": {
"@elysiajs/opentelemetry": "^1.4.0",
"@sentry/elysia": "latest || *",
"elysia": "^1.4.0"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"bun-types": "^1.2.9"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: `bun src/app.ts`,
});

export default config;
142 changes: 142 additions & 0 deletions dev-packages/e2e-tests/test-applications/bun-elysia/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import * as Sentry from '@sentry/elysia';
import { Elysia } from 'elysia';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1,
tracePropagationTargets: ['http://localhost:3030', '/external-allowed'],
});

const app = Sentry.withElysia(new Elysia());

// Simple success route
app.get('/test-success', () => ({ version: 'v1' }));

// Parameterized route
app.get('/test-param/:param', ({ params }) => ({ paramWas: params.param }));

// Multiple params
app.get('/test-multi-param/:param1/:param2', ({ params }) => ({
param1: params.param1,
param2: params.param2,
}));

// Route that throws an error (will be caught by onError)
app.get('/test-exception/:id', ({ params }) => {
throw new Error(`This is an exception with id ${params.id}`);
});

// Route with a custom span
app.get('/test-transaction', () => {
Sentry.startSpan({ name: 'test-span' }, () => {
Sentry.startSpan({ name: 'child-span' }, () => {});
});
return { status: 'ok' };
});

// Route with specific middleware via .guard or .use
app.group('/with-middleware', app =>
app
.onBeforeHandle(() => {
// This is a route-specific middleware
})
.get('/test', () => ({ middleware: true })),
);

// Error with specific status code
app.post('/test-post-error', () => {
throw new Error('Post error');
});

// Route that returns a non-500 error
app.get('/test-4xx', ({ set }) => {
set.status = 400;
return { error: 'Bad Request' };
});

// Error that reaches the error handler with status still set to 200 (unusual, should still be captured)
app.get('/test-error-with-200-status', ({ set }) => {
set.status = 200;
throw new Error('Error with 200 status');
});

// POST route that echoes body
app.post('/test-post', ({ body }) => ({ status: 'ok', body }));

// Route that returns inbound headers (for propagation tests)
app.get('/test-inbound-headers/:id', ({ params, request }) => {
const headers = Object.fromEntries(request.headers.entries());
return { headers, id: params.id };
});

// Outgoing fetch propagation
app.get('/test-outgoing-fetch/:id', async ({ params }) => {
const id = params.id;
const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`);
const data = await response.json();
return data;
});

// Outgoing fetch to external (allowed by tracePropagationTargets)
app.get('/test-outgoing-fetch-external-allowed', async () => {
const response = await fetch(`http://localhost:3040/external-allowed`);
const data = await response.json();
return data;
});

// Outgoing fetch to external (disallowed by tracePropagationTargets)
app.get('/test-outgoing-fetch-external-disallowed', async () => {
const response = await fetch(`http://localhost:3040/external-disallowed`);
const data = await response.json();
return data;
});

// Route that throws a string (not an Error object)
app.get('/test-string-error', () => {
// eslint-disable-next-line no-throw-literal
throw 'String error message';
});

// Route for concurrent isolation tests — returns scope data in response
app.get('/test-isolation/:userId', async ({ params }) => {
Sentry.setUser({ id: params.userId });
Sentry.setTag('user_id', params.userId);

// Simulate async work to increase overlap between concurrent requests
await new Promise(resolve => setTimeout(resolve, 200));

return {
userId: params.userId,
isolationScopeUserId: Sentry.getIsolationScope().getUser()?.id,
isolationScopeTag: Sentry.getIsolationScope().getScopeData().tags?.user_id,
};
});

// Flush route for waiting on events
app.get('/flush', async () => {
await Sentry.flush();
return { ok: true };
});

app.listen(3030, () => {
console.log('Elysia app listening on port 3030');
});

// Second app for external propagation tests
const app2 = new Elysia();

app2.get('/external-allowed', ({ request }) => {
const headers = Object.fromEntries(request.headers.entries());
return { headers, route: '/external-allowed' };
});

app2.get('/external-disallowed', ({ request }) => {
const headers = Object.fromEntries(request.headers.entries());
return { headers, route: '/external-disallowed' };
});

app2.listen(3040, () => {
console.log('External app listening on port 3040');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'bun-elysia',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

test('Captures an error thrown in a route handler', async ({ baseURL, request }) => {
const errorEventPromise = waitForError('bun-elysia', event => {
return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123';
});

await request.get(`${baseURL}/test-exception/123`);

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values).toHaveLength(1);
const exception = errorEvent.exception?.values?.[0];
expect(exception?.value).toBe('This is an exception with id 123');
expect(exception?.mechanism).toEqual({
type: 'elysia',
handled: false,
});

expect(errorEvent.transaction).toEqual('GET /test-exception/:id');

expect(errorEvent.contexts?.trace).toEqual(
expect.objectContaining({
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
}),
);
});

test('Error event includes request metadata', async ({ baseURL, request }) => {
const errorEventPromise = waitForError('bun-elysia', event => {
return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 456';
});

await request.get(`${baseURL}/test-exception/456`);

const errorEvent = await errorEventPromise;

expect(errorEvent.request).toEqual(
expect.objectContaining({
method: 'GET',
url: expect.stringContaining('/test-exception/456'),
headers: expect.any(Object),
}),
);
});

test('Does not capture errors for 4xx responses', async ({ baseURL, request }) => {
const transactionPromise = waitForTransaction('bun-elysia', transactionEvent => {
return transactionEvent?.transaction === 'GET /test-4xx';
});

const response = await request.get(`${baseURL}/test-4xx`);
// Wait for the transaction to ensure the request was processed
await transactionPromise;

expect(response.status()).toBe(400);
});

test('Captures errors even when status is <= 299 in error handler', async ({ baseURL, request }) => {
const errorEventPromise = waitForError('bun-elysia', event => {
return !event.type && event.exception?.values?.[0]?.value === 'Error with 200 status';
});

await request.get(`${baseURL}/test-error-with-200-status`);

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values?.[0]?.value).toBe('Error with 200 status');
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
type: 'elysia',
handled: false,
});
});

test('Captures POST route errors', async ({ baseURL, request }) => {
const errorEventPromise = waitForError('bun-elysia', event => {
return !event.type && event.exception?.values?.[0]?.value === 'Post error';
});

await request.post(`${baseURL}/test-post-error`);

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values?.[0]?.value).toBe('Post error');
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
type: 'elysia',
handled: false,
});
});

test('Captures thrown string errors', async ({ baseURL, request }) => {
const errorEventPromise = waitForError('bun-elysia', event => {
return !event.type && event.exception?.values?.[0]?.value === 'String error message';
});

await request.get(`${baseURL}/test-string-error`);

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values?.[0]?.value).toBe('String error message');
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({
type: 'elysia',
handled: false,
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';

// The Elysia integration currently does not fork isolation scopes per request,
// so `setUser`/`setTag` on the isolation scope leaks between concurrent requests.
// This test documents the expected behavior once per-request isolation is implemented.
test.fixme('Concurrent requests have isolated scope data', async ({ baseURL }) => {
// Fire 3 concurrent requests with different user IDs
const [response1, response2, response3] = await Promise.all([
fetch(`${baseURL}/test-isolation/user-1`),
fetch(`${baseURL}/test-isolation/user-2`),
fetch(`${baseURL}/test-isolation/user-3`),
]);

const data1 = await response1.json();
const data2 = await response2.json();
const data3 = await response3.json();

// Each response should have its own user ID — no leaking between requests
expect(data1.userId).toBe('user-1');
expect(data1.isolationScopeUserId).toBe('user-1');
expect(data1.isolationScopeTag).toBe('user-1');

expect(data2.userId).toBe('user-2');
expect(data2.isolationScopeUserId).toBe('user-2');
expect(data2.isolationScopeTag).toBe('user-2');

expect(data3.userId).toBe('user-3');
expect(data3.isolationScopeUserId).toBe('user-3');
expect(data3.isolationScopeTag).toBe('user-3');
});
Loading
Loading