Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export { DenoClient } from './client';
export { getDefaultIntegrations, init } from './sdk';
export { denoServeIntegration } from './integrations/deno-serve';
export type { DenoServeIntegrationOptions } from './integrations/deno-serve';
export { fetchIntegration } from './integrations/fetch';
export { denoHttpIntegration } from './integrations/http';
export type { DenoHttpIntegrationOptions } from './integrations/http';

Expand Down
87 changes: 87 additions & 0 deletions packages/deno/src/integrations/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Client, IntegrationFn, Span } from '@sentry/core';
import {
addFetchInstrumentationHandler,
defineIntegration,
getClient,
instrumentFetchRequest,
isSentryRequestUrl,
LRUMap,
shouldPropagateTraceForUrl,
} from '@sentry/core';

const INTEGRATION_NAME = 'Fetch' as const;

const HAS_CLIENT_MAP = new WeakMap<Client, boolean>();

interface FetchOptions {
/**
* Function determining whether or not to create spans to track outgoing requests to the given URL.
* By default, spans will be created for all outgoing requests.
*/
shouldCreateSpanForRequest?: (url: string) => boolean;
}

const _fetchIntegration = ((options: FetchOptions = {}) => {
const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest;

const _createSpanUrlMap = new LRUMap<string, boolean>(100);
const _headersUrlMap = new LRUMap<string, boolean>(100);

const spans: Record<string, Span> = {};

function _shouldAttachTraceData(url: string): boolean {
const client = getClient();

if (!client) {
return false;
}

return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap);
}

function _shouldCreateSpan(url: string): boolean {
if (shouldCreateSpanForRequest === undefined) {
return true;
}

const cachedDecision = _createSpanUrlMap.get(url);
if (cachedDecision !== undefined) {
return cachedDecision;
}

const decision = shouldCreateSpanForRequest(url);
_createSpanUrlMap.set(url, decision);
return decision;
}

return {
name: INTEGRATION_NAME,
setupOnce() {
addFetchInstrumentationHandler(handlerData => {
const client = getClient();
if (!client || !HAS_CLIENT_MAP.get(client)) {
return;
}

if (isSentryRequestUrl(handlerData.fetchData.url, client)) {
return;
}

const { propagateTraceparent } = client.getOptions();
instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, {
spanOrigin: 'auto.http.fetch',
propagateTraceparent,
});
});
},
setup(client) {
HAS_CLIENT_MAP.set(client, true);
},
};
}) satisfies IntegrationFn;

/**
* Instruments outgoing `fetch` requests in Deno by creating spans and attaching trace propagation headers.
* The separate breadcrumbs integration continues to record fetch breadcrumbs.
*/
export const fetchIntegration = defineIntegration(_fetchIntegration);
2 changes: 2 additions & 0 deletions packages/deno/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { breadcrumbsIntegration } from './integrations/breadcrumbs';
import { denoContextIntegration } from './integrations/context';
import { contextLinesIntegration } from './integrations/contextlines';
import { denoServeIntegration } from './integrations/deno-serve';
import { fetchIntegration } from './integrations/fetch';
import { denoHttpIntegration } from './integrations/http';
import { globalHandlersIntegration } from './integrations/globalhandlers';
import { normalizePathsIntegration } from './integrations/normalizepaths';
Expand All @@ -39,6 +40,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
breadcrumbsIntegration(),
denoContextIntegration(),
denoServeIntegration(),
fetchIntegration(),
denoHttpIntegration(),
contextLinesIntegration(),
normalizePathsIntegration(),
Expand Down
3 changes: 3 additions & 0 deletions packages/deno/test/__snapshots__/mod.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ snapshot[`captureMessage 1`] = `
"Breadcrumbs",
"DenoContext",
"DenoServe",
"Fetch",
"DenoHttp",
"ContextLines",
"NormalizePaths",
Expand Down Expand Up @@ -162,6 +163,7 @@ snapshot[`captureMessage twice 1`] = `
"Breadcrumbs",
"DenoContext",
"DenoServe",
"Fetch",
"DenoHttp",
"ContextLines",
"NormalizePaths",
Expand Down Expand Up @@ -276,6 +278,7 @@ snapshot[`captureMessage twice 2`] = `
"Breadcrumbs",
"DenoContext",
"DenoServe",
"Fetch",
"DenoHttp",
"ContextLines",
"NormalizePaths",
Expand Down
107 changes: 107 additions & 0 deletions packages/deno/test/deno-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/// <reference lib="deno.ns" />

import type { Event, TransactionEvent } from '@sentry/core';
import { getMainCarrier } from '@sentry/core';
import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts';
import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts';
import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts';
import type { DenoClient } from '../build/esm/index.js';
import { captureMessage, init, startSpan } from '../build/esm/index.js';

function resetGlobals(): void {
getMainCarrier().__SENTRY__ = undefined;
}

function withTimeout<T>(promise: Promise<T>, ms: number, description: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description} after ${ms}ms`)), ms);
});

return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) {
clearTimeout(timer);
}
});
}

Deno.test({
name: 'fetchIntegration: included in default integrations',
fn() {
resetGlobals();
const client = init({ dsn: 'https://username@domain/123' }) as DenoClient;
const names = client.getOptions().integrations.map(integration => integration.name);

assert(names.includes('Fetch'), `Fetch should be a default integration, got ${names.join(', ')}`);
},
});

Deno.test({
name: 'fetchIntegration: creates a child span, propagates trace headers, and preserves a single fetch breadcrumb',
async fn() {
resetGlobals();

const abortController = new AbortController();
let onListen: ((value: unknown) => void) | undefined;
const listening = new Promise(resolve => (onListen = resolve));
let receivedHeaders: Headers | undefined;
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen, hostname: '127.0.0.1' }, request => {
receivedHeaders = request.headers;
return new Response('ok');
});
await listening;

try {
const url = `http://127.0.0.1:${server.addr.port}/downstream`;
let resolveTransaction: ((event: TransactionEvent) => void) | undefined;
const transaction = new Promise<TransactionEvent>(resolve => (resolveTransaction = resolve));
let resolveEvent: ((event: Event) => void) | undefined;
const capturedEvent = new Promise<Event>(resolve => (resolveEvent = resolve));

init({
dsn: 'https://username@domain/123',
tracesSampleRate: 1,
traceLifecycle: 'static',
tracePropagationTargets: [url],
beforeSendTransaction(event) {
if (event.transaction === 'parent') {
resolveTransaction?.(event);
}
return null;
},
beforeSend(event) {
resolveEvent?.(event);
return null;
},
});

await startSpan({ name: 'parent', op: 'test' }, async () => {
const response = await fetch(url);
assertEquals(await response.text(), 'ok');
});

const parent = await withTimeout(transaction, 5_000, 'parent transaction');
const httpClientSpan = parent.spans?.find(span => span.op === 'http.client');
assertExists(httpClientSpan);

assertExists(receivedHeaders);
const sentryTrace = receivedHeaders.get('sentry-trace');
const baggage = receivedHeaders.get('baggage');
assertExists(sentryTrace);
assertExists(baggage);
assertEquals(sentryTrace.split('-')[0], parent.contexts?.trace?.trace_id);
assertEquals(sentryTrace.split('-')[1], httpClientSpan.span_id);
assert(baggage.includes(`sentry-trace_id=${parent.contexts?.trace?.trace_id}`));

captureMessage('capture fetch breadcrumb');
const event = await withTimeout(capturedEvent, 5_000, 'event containing fetch breadcrumb');
const fetchBreadcrumbs = event.breadcrumbs?.filter(
breadcrumb => breadcrumb.category === 'fetch' && breadcrumb.data?.url === url,
);
assertEquals(fetchBreadcrumbs?.length, 1);
} finally {
abortController.abort();
await server.finished;
}
},
});