Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1028,8 +1028,8 @@ jobs:
node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json'
- name: Set up Bun
if:
contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "hono-4", "bun-bytecode",
"bun-mysql"]'), matrix.test-application)
contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4",
"bun-bytecode", "bun-mysql"]'), matrix.test-application)
Comment thread
sentry[bot] marked this conversation as resolved.
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
Expand Down
1 change: 0 additions & 1 deletion dev-packages/bun-integration-tests/suites/basic/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as Sentry from '@sentry/bun';

Sentry.init({
traceLifecycle: 'static',
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
});
Expand Down
4 changes: 2 additions & 2 deletions dev-packages/bun-integration-tests/suites/basic/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ it('captures an error thrown in Bun.serve fetch handler', async ({ signal }) =>
{ includeSampleRand: true, includeTransaction: false },
),
)
.ignore('transaction')
.ignore('span')
.start(signal);
await runner.makeRequest('get', '/error', { expectError: true });
await runner.completed();
Expand All @@ -47,7 +47,7 @@ it('captures a manually sent message', async ({ signal }) => {
message: 'Hello from Bun',
});
})
.ignore('transaction')
.ignore('span')
.start(signal);
await runner.makeRequest('get', '/message');
await runner.completed();
Expand Down
7 changes: 6 additions & 1 deletion dev-packages/bun-integration-tests/suites/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const targetServer = Bun.serve({
const targetUrl = `http://localhost:${targetServer.port}`;

Sentry.init({
traceLifecycle: 'static',
environment: 'production',
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
Expand All @@ -39,6 +38,12 @@ const server = Bun.serve({
return Response.json(data);
}

if (url.pathname === '/outgoing-fetch-error') {
await fetch(`${targetUrl}/allowed`);
Sentry.captureException(new Error('fetch done'));
return new Response('OK');
}

return new Response('Hello from Bun!');
},
});
Expand Down
70 changes: 43 additions & 27 deletions dev-packages/bun-integration-tests/suites/fetch/test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,38 @@
import type { Envelope, TransactionEvent } from '@sentry/core';
import type { Envelope, Event, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../runner';

function getTransaction(envelope: Envelope): TransactionEvent {
return envelope[1][0][1] as TransactionEvent;
function getSpans(envelope: Envelope): SerializedStreamedSpan[] {
return (envelope[1][0][1] as SerializedStreamedSpanContainer).items;
}

it('creates an http.client span for outgoing fetch requests', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transaction = getTransaction(envelope);

expect(transaction.transaction).toBe('GET /outgoing-fetch');
const spans = getSpans(envelope);

const segmentSpan = spans.find(span => span.is_segment);
expect(segmentSpan).toMatchObject({
// `Bun.serve` without `routes` has no parameterized route, so the streamed segment is
// named after the method only; the path lives in `url.path`.
name: 'GET',
attributes: expect.objectContaining({
'sentry.op': { value: 'http.server', type: 'string' },
'url.path': { value: '/outgoing-fetch', type: 'string' },
}),
});

const httpClientSpan = transaction.spans?.find(span => span.op === 'http.client');
const httpClientSpan = spans.find(span => span.attributes['sentry.op']?.value === 'http.client');

expect(httpClientSpan).toBeDefined();
expect(httpClientSpan).toMatchObject({
op: 'http.client',
origin: 'auto.http.fetch',
description: expect.stringMatching(/^GET http:\/\/localhost:\d+\/allowed$/),
data: expect.objectContaining({
'http.request.method': 'GET',
type: 'fetch',
name: 'GET localhost',
parent_span_id: segmentSpan!.span_id,
attributes: expect.objectContaining({
'sentry.op': { value: 'http.client', type: 'string' },
'sentry.origin': { value: 'auto.http.fetch', type: 'string' },
'http.request.method': { value: 'GET', type: 'string' },
type: { value: 'fetch', type: 'string' },
}),
});
})
Expand Down Expand Up @@ -54,25 +64,31 @@ it('does not propagate headers to outgoing fetch requests outside tracePropagati
});

it('records a breadcrumb for outgoing fetch requests', async ({ signal }) => {
// Streamed spans carry no breadcrumbs, so the breadcrumb is asserted on an error
// captured right after the fetch instead.
const runner = createRunner(__dirname)
.expect(envelope => {
const transaction = getTransaction(envelope);

const fetchBreadcrumb = transaction.breadcrumbs?.find(
breadcrumb => breadcrumb.category === 'fetch' && (breadcrumb.data?.url as string)?.includes('/allowed'),
);

expect(fetchBreadcrumb).toMatchObject({
category: 'fetch',
type: 'http',
data: expect.objectContaining({
method: 'GET',
status_code: 200,
const [, envelopeItems] = envelope;
const [itemHeader, event] = envelopeItems[0] as [{ type: string }, Event];

expect(itemHeader.type).toBe('event');
expect(event.exception?.values?.[0]?.value).toBe('fetch done');

expect(event.breadcrumbs).toContainEqual(
expect.objectContaining({
category: 'fetch',
type: 'http',
data: expect.objectContaining({
method: 'GET',
status_code: 200,
url: expect.stringMatching(/\/allowed$/),
}),
}),
});
);
})
.ignore('span')
.start(signal);

await runner.makeRequest('get', '/outgoing-fetch');
await runner.makeRequest('get', '/outgoing-fetch-error');
await runner.completed();
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ app.use(
sentry(app, {
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
traceLifecycle: 'static',
}),
);

Expand Down
89 changes: 34 additions & 55 deletions dev-packages/bun-integration-tests/suites/hono-sdk/test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { expect, it } from 'vitest';
import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect';
import { createRunner } from '../../runner';
Expand All @@ -8,43 +9,25 @@ it('Hono app captures parametrized errors (Hono SDK on Bun)', async ({ signal })
const [, envelopeItems] = envelope;
const [itemHeader, itemPayload] = envelopeItems[0];

expect(itemHeader.type).toBe('transaction');
expect(itemHeader.type).toBe('span');

expect(itemPayload).toMatchObject({
type: 'transaction',
platform: 'node',
transaction: 'GET /error/:param',
transaction_info: {
source: 'route',
},
contexts: {
trace: {
span_id: expect.any(String),
trace_id: expect.any(String),
op: 'http.server',
status: 'internal_error',
origin: 'auto.http.bun.serve',
},
response: {
status_code: 500,
},
},
request: expect.objectContaining({
method: 'GET',
url: expect.stringContaining('/error/param-123'),
const segmentSpan = (itemPayload as SerializedStreamedSpanContainer).items.find(span => span.is_segment);

expect(segmentSpan).toMatchObject({
name: 'GET /error/:param',
is_segment: true,
span_id: expect.any(String),
trace_id: expect.any(String),
status: 'error',
attributes: expect.objectContaining({
'sentry.op': { value: 'http.server', type: 'string' },
'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' },
'sentry.segment.name.source': { value: 'route', type: 'string' },
'http.route': { value: '/error/:param', type: 'string' },
'http.request.method': { value: 'GET', type: 'string' },
'http.response.status_code': { value: 500, type: 'integer' },
'url.path': { value: '/error/param-123', type: 'string' },
}),
breadcrumbs: [
{
timestamp: expect.any(Number),
category: 'console',
level: 'error',
message: 'Error: Test error from Hono app',
data: expect.objectContaining({
logger: 'console',
arguments: [{ message: 'Test error from Hono app', name: 'Error', stack: expect.any(String) }],
}),
},
],
});
})

Expand Down Expand Up @@ -101,27 +84,23 @@ it('Hono app captures parametrized route names on Bun', async ({ signal }) => {
const [, envelopeItems] = envelope;
const [itemHeader, itemPayload] = envelopeItems[0];

expect(itemHeader.type).toBe('transaction');
expect(itemHeader.type).toBe('span');

expect(itemPayload).toMatchObject({
type: 'transaction',
platform: 'node',
transaction: 'GET /hello/:name',
transaction_info: {
source: 'route',
},
contexts: {
trace: {
span_id: SHORT_UUID_MATCHER,
trace_id: UUID_MATCHER,
op: 'http.server',
status: 'ok',
origin: 'auto.http.bun.serve',
},
},
request: expect.objectContaining({
method: 'GET',
url: expect.stringContaining('/hello/world'),
const segmentSpan = (itemPayload as SerializedStreamedSpanContainer).items.find(span => span.is_segment);

expect(segmentSpan).toMatchObject({
name: 'GET /hello/:name',
is_segment: true,
span_id: SHORT_UUID_MATCHER,
trace_id: UUID_MATCHER,
status: 'ok',
attributes: expect.objectContaining({
'sentry.op': { value: 'http.server', type: 'string' },
'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' },
'sentry.segment.name.source': { value: 'route', type: 'string' },
'http.route': { value: '/hello/:name', type: 'string' },
'http.request.method': { value: 'GET', type: 'string' },
'url.path': { value: '/hello/world', type: 'string' },
}),
});
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import * as Sentry from '@sentry/bun';

Sentry.init({
traceLifecycle: 'static',
dsn: 'https://username@domain/123',
tracesSampleRate: 0,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import * as Sentry from '@sentry/bun';
import mysql from 'mysql';

Sentry.init({
traceLifecycle: 'static',
environment: 'qa',
dsn: process.env.E2E_TEST_DSN,
debug: !!process.env.DEBUG,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,55 +1,64 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
import type { SerializedStreamedSpan } from '@sentry/core';

// `Bun.serve` without `routes` has no parameterized route, so with span streaming the
// http.server segment is named after the method only; the path lives in `url.path`.
function isTestMysqlSegment(span: SerializedStreamedSpan): boolean {
return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-mysql';
}

test('mysql queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => {
// Each incoming request gets a Sentry http.server transaction; the mysql
// queries run inside it, so their db spans attach to that transaction. The
// channels were injected at build time by `@sentry/bun/plugin`, and the Bun
// SDK subscribes to them by default.
const transactionPromise = waitForTransaction('bun-mysql', event => {
return (
event?.contexts?.trace?.op === 'http.server' &&
(event.request?.url ?? '').includes('/test-mysql') &&
(event.spans?.some(span => span.op === 'db') ?? false)
);
});
// Each incoming request gets a Sentry http.server segment span; the mysql
// queries run inside it, so their db spans join that trace. The channels
// were injected at build time by `@sentry/bun/plugin`, and the Bun SDK
// subscribes to them by default.
const spansPromise = collectStreamedSpans(
'bun-mysql',
spans => spans.some(isTestMysqlSegment) && spans.some(span => getSpanOp(span) === 'db'),
);

const res = await fetch(`${baseURL}/test-mysql`);
expect(res.status).toBe(200);
await res.json();

const transaction = await transactionPromise;
const dbSpans = transaction.spans!.filter(span => span.op === 'db');
const spans = await spansPromise;
const dbSpans = spans.filter(span => getSpanOp(span) === 'db');

const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution');
expect(firstQuery).toBeDefined();
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql');
expect(firstQuery!.data?.['db.system.name']).toBe('mysql');
expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution');
expect(firstQuery!.data?.['server.port']).toBe(3306);
expect(firstQuery!.data?.['db.user']).toBe('root');
// With span streaming, db span names are the low-cardinality query summary, not the raw SQL
expect(firstQuery!.name).toBe('SELECT');
expect(firstQuery!.attributes).toMatchObject({
'sentry.origin': { value: 'auto.db.mysql', type: 'string' },
'db.system.name': { value: 'mysql', type: 'string' },
'db.query.text': { value: 'SELECT 1 + 1 AS solution', type: 'string' },
'server.port': { value: 3306, type: 'integer' },
'db.user': { value: 'root', type: 'string' },
});
});

test('a nested query lands on the same transaction (async context restored)', async ({ baseURL }) => {
test('a nested query lands on the same trace (async context restored)', async ({ baseURL }) => {
// The second query runs inside the first query's callback — i.e. across
// mysql's async socket-callback dispatch. Both spans appearing on the SAME
// http.server transaction proves the channel subscriber restored the parent
// mysql's async socket-callback dispatch. Both db spans being children of the
// SAME http.server segment proves the channel subscriber restored the parent
// span across that async boundary (otherwise the nested query would start its
// own trace and never join this transaction).
const transactionPromise = waitForTransaction('bun-mysql', event => {
return (
event?.contexts?.trace?.op === 'http.server' &&
(event.request?.url ?? '').includes('/test-mysql') &&
(event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
);
});
// own trace and never join this one).
const spansPromise = collectStreamedSpans(
'bun-mysql',
spans => spans.some(isTestMysqlSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2,
);

const res = await fetch(`${baseURL}/test-mysql`);
expect(res.status).toBe(200);
await res.json();

const transaction = await transactionPromise;
const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description);
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
expect(descriptions).toContain('SELECT NOW()');
const spans = await spansPromise;
const segment = spans.find(isTestMysqlSegment)!;
const dbSpans = spans.filter(span => getSpanOp(span) === 'db');

const queries = dbSpans.map(span => span.attributes['db.query.text']?.value);
expect(queries).toContain('SELECT 1 + 1 AS solution');
expect(queries).toContain('SELECT NOW()');
expect(dbSpans.every(span => span.parent_span_id === segment.span_id)).toBe(true);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
Loading
Loading