From 9705629ecc54852c751c6bb9546b35dee5e7b6bd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 10:39:41 +0200 Subject: [PATCH 1/5] feat(google-cloud-serverless): Emit low cardinality `function.gcp` span names --- packages/core/src/tracing/spans/spanNames.ts | 6 + .../src/gcpfunction/cloud_events.ts | 40 +++- .../src/gcpfunction/events.ts | 38 +++- .../src/gcpfunction/http.ts | 29 ++- packages/google-cloud-serverless/src/utils.ts | 12 + .../test/gcpfunction/cloud_event.test.ts | 215 +++++++++++++++--- .../test/gcpfunction/events.test.ts | 208 ++++++++++++++--- .../test/gcpfunction/http.test.ts | 126 +++++++++- 8 files changed, 602 insertions(+), 72 deletions(-) diff --git a/packages/core/src/tracing/spans/spanNames.ts b/packages/core/src/tracing/spans/spanNames.ts index 082baccfd74b..b0a0e29b9eb9 100644 --- a/packages/core/src/tracing/spans/spanNames.ts +++ b/packages/core/src/tracing/spans/spanNames.ts @@ -82,6 +82,12 @@ export const ROUTER_SPAN_NAME_FALLBACK = 'Router'; */ export const REQUEST_HANDLER_SPAN_NAME_FALLBACK = 'Request handler'; +/** + * Fallback name for serverless function execution spans when no better-suited span name is available. + * @see https://getsentry.github.io/sentry-conventions/names/#faas-serverless-function-execution + */ +export const SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK = 'Serverless function execution'; + /** * The `cache.operation` attribute value each cache op carries. Cache span names are * `cache.{{cache.operation}}`, so the op constant itself doubles as the low-cardinality span name. diff --git a/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts b/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts index b8927c5fdc0f..eb1002be9532 100644 --- a/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts +++ b/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts @@ -1,9 +1,26 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, FAAS_TRIGGER, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + FAAS_NAME, + FAAS_TRIGGER, + SENTRY_OP, + GCP_FUNCTION_CONTEXT_TYPE, + GCP_FUNCTION_CONTEXT_ID, + GCP_FUNCTION_CONTEXT_SOURCE, + GCP_FUNCTION_CONTEXT_SPECVERSION, + GCP_FUNCTION_CONTEXT_TIME, +} from '@sentry/conventions/attributes'; import { FUNCTION_GCP } from '@sentry/conventions/op'; -import { debug, handleCallbackErrors, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + debug, + getClient, + handleCallbackErrors, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, +} from '@sentry/core'; import { captureException, flush, getCurrentScope, startSpanManual } from '@sentry/node'; import { DEBUG_BUILD } from '../debug-build'; -import { domainify, markEventUnhandled, proxyFunction } from '../utils'; +import { domainify, getFunctionName, markEventUnhandled, proxyFunction } from '../utils'; import type { CloudEventFunction, CloudEventFunctionWithCallback, WrapperOptions } from './general'; export type CloudEventFunctionWrapperOptions = WrapperOptions; @@ -31,14 +48,29 @@ function _wrapCloudEventFunction( ...wrapOptions, }; return (context, callback) => { + const client = getClient(); + + const functionName = getFunctionName(); + const name = + client && hasSpanStreamingEnabled(client) + ? functionName || SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK + : context.type || ''; + return startSpanManual( { - name: context.type || '', + name, attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: functionName, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + // not yet in conventions but this attribute will also determine the span description + [GCP_FUNCTION_CONTEXT_TYPE]: context.type, + [GCP_FUNCTION_CONTEXT_ID]: context.id, + [GCP_FUNCTION_CONTEXT_SOURCE]: context.source, + [GCP_FUNCTION_CONTEXT_SPECVERSION]: context.specversion, + [GCP_FUNCTION_CONTEXT_TIME]: context.time, }, }, span => { diff --git a/packages/google-cloud-serverless/src/gcpfunction/events.ts b/packages/google-cloud-serverless/src/gcpfunction/events.ts index cae2d2783e21..eb48221843ef 100644 --- a/packages/google-cloud-serverless/src/gcpfunction/events.ts +++ b/packages/google-cloud-serverless/src/gcpfunction/events.ts @@ -1,9 +1,25 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, FAAS_TRIGGER, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + FAAS_NAME, + FAAS_TRIGGER, + SENTRY_OP, + GCP_FUNCTION_CONTEXT_EVENT_TYPE, + GCP_FUNCTION_CONTEXT_EVENT_ID, + GCP_FUNCTION_CONTEXT_RESOURCE, + GCP_FUNCTION_CONTEXT_TIMESTAMP, +} from '@sentry/conventions/attributes'; import { FUNCTION_GCP } from '@sentry/conventions/op'; -import { debug, handleCallbackErrors, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + debug, + getClient, + handleCallbackErrors, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, +} from '@sentry/core'; import { captureException, flush, getCurrentScope, startSpanManual } from '@sentry/node'; import { DEBUG_BUILD } from '../debug-build'; -import { domainify, markEventUnhandled, proxyFunction } from '../utils'; +import { domainify, getFunctionName, markEventUnhandled, proxyFunction } from '../utils'; import type { EventFunction, EventFunctionWithCallback, WrapperOptions } from './general'; export type EventFunctionWrapperOptions = WrapperOptions; @@ -34,14 +50,28 @@ function _wrapEventFunction return (...eventFunctionArguments: Parameters): ReturnType | Promise => { const [data, context, callback] = eventFunctionArguments; + const client = getClient(); + + const functionName = getFunctionName(); + const name = + client && hasSpanStreamingEnabled(client) + ? functionName || SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK + : context.eventType; + return startSpanManual( { - name: context.eventType, + name, attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: functionName, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + // not yet in conventions but this attribute will also determine the span description + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: context.eventType, + [GCP_FUNCTION_CONTEXT_EVENT_ID]: context.eventId, + [GCP_FUNCTION_CONTEXT_RESOURCE]: context.resource, + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: context.timestamp, }, }, span => { diff --git a/packages/google-cloud-serverless/src/gcpfunction/http.ts b/packages/google-cloud-serverless/src/gcpfunction/http.ts index e1caddcb753c..9c39ec940327 100644 --- a/packages/google-cloud-serverless/src/gcpfunction/http.ts +++ b/packages/google-cloud-serverless/src/gcpfunction/http.ts @@ -1,17 +1,27 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, FAAS_TRIGGER, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + FAAS_NAME, + FAAS_TRIGGER, + HTTP_REQUEST_METHOD, + SENTRY_OP, + URL_PATH, +} from '@sentry/conventions/attributes'; import { FUNCTION_GCP } from '@sentry/conventions/op'; import { debug, + getClient, handleCallbackErrors, + hasSpanStreamingEnabled, httpRequestToRequestData, isString, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, setHttpStatus, stripUrlQueryAndFragment, } from '@sentry/core'; import { captureException, continueTrace, flush, getCurrentScope, startSpanManual } from '@sentry/node'; import { DEBUG_BUILD } from '../debug-build'; -import { domainify, markEventUnhandled, proxyFunction } from '../utils'; +import { domainify, getFunctionName, markEventUnhandled, proxyFunction } from '../utils'; import type { HttpFunction, WrapperOptions } from './general'; /** @@ -49,14 +59,27 @@ function _wrapHttpFunction(fn: HttpFunction, options: Partial): const normalizedRequest = httpRequestToRequestData(req); getCurrentScope().setSDKProcessingMetadata({ normalizedRequest }); + const client = getClient(); + + const functionName = getFunctionName(); + const name = + client && hasSpanStreamingEnabled(client) + ? functionName || SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK + : `${reqMethod} ${reqUrl}`; + return startSpanManual( { - name: `${reqMethod} ${reqUrl}`, + name, attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: functionName, [FAAS_TRIGGER]: 'http', [SENTRY_SEGMENT_NAME_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', + // The method and path used to be the span name; they stay on the span so that + // information survives the low-cardinality rename. + [HTTP_REQUEST_METHOD]: reqMethod || undefined, + [URL_PATH]: reqUrl || undefined, }, }, span => { diff --git a/packages/google-cloud-serverless/src/utils.ts b/packages/google-cloud-serverless/src/utils.ts index d1d1b672bbae..81e7bdc8e0e1 100644 --- a/packages/google-cloud-serverless/src/utils.ts +++ b/packages/google-cloud-serverless/src/utils.ts @@ -51,3 +51,15 @@ export function markEventUnhandled(scope: Scope, type: string): Scope { return scope; } + +/** + * Resolves the name of the currently executing cloud function. + * + * `FUNCTION_TARGET` ("the function to be executed") is set by GCP for every deployed function, and + * by the functions-framework when running locally, where `K_SERVICE` is absent. `K_SERVICE` is the + * Cloud Run service the function runs as; the two differ whenever the entry point is named + * separately from the service, as in `gcloud run deploy my-service --function myHandler`. + */ +export function getFunctionName(): string | undefined { + return process.env.FUNCTION_TARGET || process.env.K_SERVICE || undefined; +} diff --git a/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts b/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts index 444177ac1beb..8275cdb4dd20 100644 --- a/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts +++ b/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts @@ -1,7 +1,19 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, FAAS_TRIGGER, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + FAAS_TRIGGER, + SENTRY_OP, + FAAS_NAME, + GCP_FUNCTION_CONTEXT_TYPE, + GCP_FUNCTION_CONTEXT_ID, + GCP_FUNCTION_CONTEXT_SOURCE, + GCP_FUNCTION_CONTEXT_SPECVERSION, + GCP_FUNCTION_CONTEXT_TIME, +} from '@sentry/conventions/attributes'; import { FUNCTION_GCP } from '@sentry/conventions/op'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Client } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { wrapCloudEventFunction } from '../../src/gcpfunction/cloud_events'; import type { CloudEventFunction, CloudEventFunctionWithCallback } from '../../src/gcpfunction/general'; @@ -46,9 +58,11 @@ describe('wrapCloudEventFunction', () => { function handleCloudEvent(fn: CloudEventFunctionWithCallback): Promise { return new Promise((resolve, reject) => { const context = { - id: 'test-event-id', + id: '5302804326013861', specversion: '1.0', - type: 'event.type', + type: 'google.cloud.pubsub.topic.v1.messagePublished', + source: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + time: '2026-09-04T09:00:00.123Z', }; try { @@ -73,17 +87,23 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(func); await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + [GCP_FUNCTION_CONTEXT_ID]: '5302804326013861', + [GCP_FUNCTION_CONTEXT_SOURCE]: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + [GCP_FUNCTION_CONTEXT_SPECVERSION]: '1.0', + [GCP_FUNCTION_CONTEXT_TIME]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalledWith(2000); }); @@ -99,17 +119,23 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(func); await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + [GCP_FUNCTION_CONTEXT_ID]: '5302804326013861', + [GCP_FUNCTION_CONTEXT_SOURCE]: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + [GCP_FUNCTION_CONTEXT_SPECVERSION]: '1.0', + [GCP_FUNCTION_CONTEXT_TIME]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalledWith(2000); }); @@ -126,17 +152,23 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(handler); await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + [GCP_FUNCTION_CONTEXT_ID]: '5302804326013861', + [GCP_FUNCTION_CONTEXT_SOURCE]: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + [GCP_FUNCTION_CONTEXT_SPECVERSION]: '1.0', + [GCP_FUNCTION_CONTEXT_TIME]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); const scopeFunction = mockCaptureException.mock.calls[0][1]; @@ -164,17 +196,23 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(handler); await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + [GCP_FUNCTION_CONTEXT_ID]: '5302804326013861', + [GCP_FUNCTION_CONTEXT_SOURCE]: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + [GCP_FUNCTION_CONTEXT_SPECVERSION]: '1.0', + [GCP_FUNCTION_CONTEXT_TIME]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); const scopeFunction = mockCaptureException.mock.calls[0][1]; @@ -202,17 +240,23 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(func); await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + [GCP_FUNCTION_CONTEXT_ID]: '5302804326013861', + [GCP_FUNCTION_CONTEXT_SOURCE]: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + [GCP_FUNCTION_CONTEXT_SPECVERSION]: '1.0', + [GCP_FUNCTION_CONTEXT_TIME]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalledWith(2000); }); @@ -225,17 +269,23 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(handler); await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + [GCP_FUNCTION_CONTEXT_ID]: '5302804326013861', + [GCP_FUNCTION_CONTEXT_SOURCE]: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + [GCP_FUNCTION_CONTEXT_SPECVERSION]: '1.0', + [GCP_FUNCTION_CONTEXT_TIME]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); const scopeFunction = mockCaptureException.mock.calls[0][1]; @@ -262,17 +312,23 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(handler); await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSapanOptions = { + name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + [GCP_FUNCTION_CONTEXT_ID]: '5302804326013861', + [GCP_FUNCTION_CONTEXT_SOURCE]: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + [GCP_FUNCTION_CONTEXT_SPECVERSION]: '1.0', + [GCP_FUNCTION_CONTEXT_TIME]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSapanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); const scopeFunction = mockCaptureException.mock.calls[0][1]; @@ -289,14 +345,117 @@ describe('wrapCloudEventFunction', () => { }); }); + describe('wrapCloudEventFunction() with span streaming enabled', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as Client); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + test('names the span after the function name from FUNCTION_TARGET', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + + const func: CloudEventFunction = _context => 42; + const wrappedHandler = wrapCloudEventFunction(func); + await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'myCloudFunction', + attributes: expect.objectContaining({ + [FAAS_NAME]: 'myCloudFunction', + // The event type stays on the span so the description can still be derived from it. + [GCP_FUNCTION_CONTEXT_TYPE]: 'google.cloud.pubsub.topic.v1.messagePublished', + }), + }), + expect.any(Function), + ); + }); + + test('falls back to K_SERVICE when FUNCTION_TARGET is unset', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', 'my-cloud-run-service'); + + const func: CloudEventFunction = _context => 42; + const wrappedHandler = wrapCloudEventFunction(func); + await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'my-cloud-run-service', + attributes: expect.objectContaining({ [FAAS_NAME]: 'my-cloud-run-service' }), + }), + expect.any(Function), + ); + }); + + test('falls back to the static span name when no function name is resolvable', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', ''); + + const func: CloudEventFunction = _context => 42; + const wrappedHandler = wrapCloudEventFunction(func); + await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, + attributes: expect.objectContaining({ [FAAS_NAME]: undefined }), + }), + expect.any(Function), + ); + }); + + test('names the span after the function name for callback-style handlers', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + + const func: CloudEventFunctionWithCallback = (_context, cb) => { + cb(null, 42); + }; + const wrappedHandler = wrapCloudEventFunction(func); + await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ name: 'myCloudFunction' }), + expect.any(Function), + ); + }); + + test('keeps naming the span after the event type when span streaming is disabled', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'static' }), + } as unknown as Client); + + const func: CloudEventFunction = _context => 42; + const wrappedHandler = wrapCloudEventFunction(func); + await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'google.cloud.pubsub.topic.v1.messagePublished', + attributes: expect.objectContaining({ [FAAS_NAME]: 'myCloudFunction' }), + }), + expect.any(Function), + ); + }); + }); + test('wrapCloudEventFunction scope data', async () => { const handler: CloudEventFunction = _context => 42; const wrappedHandler = wrapCloudEventFunction(handler); await handleCloudEvent(wrappedHandler); expect(mockScope.setContext).toBeCalledWith('gcp.function.context', { - id: 'test-event-id', + id: '5302804326013861', specversion: '1.0', - type: 'event.type', + type: 'google.cloud.pubsub.topic.v1.messagePublished', + source: '//pubsub.googleapis.com/projects/my-project/topics/my-topic', + time: '2026-09-04T09:00:00.123Z', }); }); }); diff --git a/packages/google-cloud-serverless/test/gcpfunction/events.test.ts b/packages/google-cloud-serverless/test/gcpfunction/events.test.ts index 1845b60de5ed..95d1b6972924 100644 --- a/packages/google-cloud-serverless/test/gcpfunction/events.test.ts +++ b/packages/google-cloud-serverless/test/gcpfunction/events.test.ts @@ -1,8 +1,18 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, FAAS_TRIGGER, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + FAAS_TRIGGER, + SENTRY_OP, + FAAS_NAME, + GCP_FUNCTION_CONTEXT_EVENT_TYPE, + GCP_FUNCTION_CONTEXT_EVENT_ID, + GCP_FUNCTION_CONTEXT_RESOURCE, + GCP_FUNCTION_CONTEXT_TIMESTAMP, +} from '@sentry/conventions/attributes'; import { FUNCTION_GCP } from '@sentry/conventions/op'; -import type { Event } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Client, Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { wrapEventFunction } from '../../src/gcpfunction/events'; import type { EventFunction, EventFunctionWithCallback } from '../../src/gcpfunction/general'; @@ -47,8 +57,10 @@ describe('wrapEventFunction', () => { function handleEvent(fn: EventFunctionWithCallback): Promise { return new Promise((resolve, reject) => { const context = { - eventType: 'event.type', - resource: 'some.resource', + eventId: '1144231683168617', + timestamp: '2026-09-04T09:00:00.123Z', + eventType: 'providers/cloud.firestore/eventTypes/document.write', + resource: 'projects/my-project/databases/(default)/documents/users/abc123', }; fn({}, context, (err: any, result: any) => { @@ -69,17 +81,22 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(func); await expect(handleEvent(wrappedHandler)).resolves.toBe(42); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'providers/cloud.firestore/eventTypes/document.write', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + [GCP_FUNCTION_CONTEXT_EVENT_ID]: '1144231683168617', + [GCP_FUNCTION_CONTEXT_RESOURCE]: 'projects/my-project/databases/(default)/documents/users/abc123', + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalledWith(2000); }); @@ -92,17 +109,22 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(handler); await expect(handleEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'providers/cloud.firestore/eventTypes/document.write', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + [GCP_FUNCTION_CONTEXT_EVENT_ID]: '1144231683168617', + [GCP_FUNCTION_CONTEXT_RESOURCE]: 'projects/my-project/databases/(default)/documents/users/abc123', + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalled(); @@ -120,17 +142,22 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(func); await expect(handleEvent(wrappedHandler)).resolves.toBe(42); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'providers/cloud.firestore/eventTypes/document.write', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + [GCP_FUNCTION_CONTEXT_EVENT_ID]: '1144231683168617', + [GCP_FUNCTION_CONTEXT_RESOURCE]: 'projects/my-project/databases/(default)/documents/users/abc123', + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalledWith(2000); }); @@ -147,17 +174,22 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(handler); await expect(handleEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'providers/cloud.firestore/eventTypes/document.write', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + [GCP_FUNCTION_CONTEXT_EVENT_ID]: '1144231683168617', + [GCP_FUNCTION_CONTEXT_RESOURCE]: 'projects/my-project/databases/(default)/documents/users/abc123', + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalled(); @@ -172,17 +204,22 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(func); await expect(handleEvent(wrappedHandler)).resolves.toBe(42); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'providers/cloud.firestore/eventTypes/document.write', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + [GCP_FUNCTION_CONTEXT_EVENT_ID]: '1144231683168617', + [GCP_FUNCTION_CONTEXT_RESOURCE]: 'projects/my-project/databases/(default)/documents/users/abc123', + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalledWith(2000); }); @@ -195,17 +232,22 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(handler); await expect(handleEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'providers/cloud.firestore/eventTypes/document.write', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + [GCP_FUNCTION_CONTEXT_EVENT_ID]: '1144231683168617', + [GCP_FUNCTION_CONTEXT_RESOURCE]: 'projects/my-project/databases/(default)/documents/users/abc123', + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); expect(mockSpan.end).toBeCalled(); expect(mockFlush).toBeCalled(); @@ -219,21 +261,127 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(handler); await expect(handleEvent(wrappedHandler)).rejects.toThrowError(error); - const fakeTransactionContext = { - name: 'event.type', + const expectedStartSpanOptions = { + name: 'providers/cloud.firestore/eventTypes/document.write', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + [GCP_FUNCTION_CONTEXT_EVENT_ID]: '1144231683168617', + [GCP_FUNCTION_CONTEXT_RESOURCE]: 'projects/my-project/databases/(default)/documents/users/abc123', + [GCP_FUNCTION_CONTEXT_TIMESTAMP]: '2026-09-04T09:00:00.123Z', }, }; - expect(mockStartSpanManual).toBeCalledWith(fakeTransactionContext, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); }); }); + describe('wrapEventFunction() with span streaming enabled', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as Client); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + test('names the span after the function name from FUNCTION_TARGET', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + + const func: EventFunction = (_data, _context) => 42; + const wrappedHandler = wrapEventFunction(func); + await expect(handleEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'myCloudFunction', + attributes: expect.objectContaining({ + [FAAS_NAME]: 'myCloudFunction', + // The event type stays on the span so the description can still be derived from it. + [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: 'providers/cloud.firestore/eventTypes/document.write', + }), + }), + expect.any(Function), + ); + }); + + test('falls back to K_SERVICE when FUNCTION_TARGET is unset', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', 'my-cloud-run-service'); + + const func: EventFunction = (_data, _context) => 42; + const wrappedHandler = wrapEventFunction(func); + await expect(handleEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'my-cloud-run-service', + attributes: expect.objectContaining({ [FAAS_NAME]: 'my-cloud-run-service' }), + }), + expect.any(Function), + ); + }); + + test('falls back to the static span name when no function name is resolvable', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', ''); + + const func: EventFunction = (_data, _context) => 42; + const wrappedHandler = wrapEventFunction(func); + await expect(handleEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, + attributes: expect.objectContaining({ [FAAS_NAME]: undefined }), + }), + expect.any(Function), + ); + }); + + test('names the span after the function name for callback-style handlers', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + + const func: EventFunctionWithCallback = (_data, _context, cb) => { + cb(null, 42); + }; + const wrappedHandler = wrapEventFunction(func); + await expect(handleEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ name: 'myCloudFunction' }), + expect.any(Function), + ); + }); + + test('keeps naming the span after the event type when span streaming is disabled', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'static' }), + } as unknown as Client); + + const func: EventFunction = (_data, _context) => 42; + const wrappedHandler = wrapEventFunction(func); + await expect(handleEvent(wrappedHandler)).resolves.toBe(42); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'providers/cloud.firestore/eventTypes/document.write', + attributes: expect.objectContaining({ [FAAS_NAME]: 'myCloudFunction' }), + }), + expect.any(Function), + ); + }); + }); + test('marks the captured error as unhandled', async () => { const error = new Error('wat'); const handler: EventFunctionWithCallback = (_data, _context, _cb) => { @@ -262,8 +410,10 @@ describe('wrapEventFunction', () => { const wrappedHandler = wrapEventFunction(handler); await handleEvent(wrappedHandler); expect(mockScope.setContext).toBeCalledWith('gcp.function.context', { - eventType: 'event.type', - resource: 'some.resource', + eventId: '1144231683168617', + timestamp: '2026-09-04T09:00:00.123Z', + eventType: 'providers/cloud.firestore/eventTypes/document.write', + resource: 'projects/my-project/databases/(default)/documents/users/abc123', }); }); }); diff --git a/packages/google-cloud-serverless/test/gcpfunction/http.test.ts b/packages/google-cloud-serverless/test/gcpfunction/http.test.ts index cd99090faf91..d852061b7c20 100644 --- a/packages/google-cloud-serverless/test/gcpfunction/http.test.ts +++ b/packages/google-cloud-serverless/test/gcpfunction/http.test.ts @@ -1,8 +1,16 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, FAAS_TRIGGER, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + FAAS_NAME, + FAAS_TRIGGER, + HTTP_REQUEST_METHOD, + SENTRY_OP, + URL_PATH, +} from '@sentry/conventions/attributes'; import { FUNCTION_GCP } from '@sentry/conventions/op'; -import type { Integration } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { beforeEach, describe, expect, type MockInstance, test, vi } from 'vitest'; +import type { Client, Integration } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, type MockInstance, test, vi } from 'vitest'; import type { HttpFunction, Request, Response } from '../../src/gcpfunction/general'; import { wrapHttpFunction } from '../../src/gcpfunction/http'; import { init } from '../../src/sdk'; @@ -96,9 +104,12 @@ describe('GCPFunction', () => { name: 'POST /path', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'http', [SENTRY_SEGMENT_NAME_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', + [HTTP_REQUEST_METHOD]: 'POST', + [URL_PATH]: '/path', }, }; @@ -120,9 +131,12 @@ describe('GCPFunction', () => { name: 'POST /path', attributes: { [SENTRY_OP]: FUNCTION_GCP, + [FAAS_NAME]: undefined, [FAAS_TRIGGER]: 'http', [SENTRY_SEGMENT_NAME_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', + [HTTP_REQUEST_METHOD]: 'POST', + [URL_PATH]: '/path', }, }; @@ -172,6 +186,110 @@ describe('GCPFunction', () => { }); }); + describe('wrapHttpFunction() with span streaming enabled', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as Client); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + test('names the span after the function name from FUNCTION_TARGET', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + + const handler: HttpFunction = (_req, res) => { + res.statusCode = 200; + res.end(); + }; + await handleHttp(wrapHttpFunction(handler)); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'myCloudFunction', + attributes: expect.objectContaining({ + [FAAS_NAME]: 'myCloudFunction', + // The method and path stay on the span even though they are no longer the name. + [HTTP_REQUEST_METHOD]: 'POST', + [URL_PATH]: '/path', + }), + }), + expect.any(Function), + ); + }); + + test('falls back to K_SERVICE when FUNCTION_TARGET is unset', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', 'my-cloud-run-service'); + + const handler: HttpFunction = (_req, res) => { + res.end(); + }; + await handleHttp(wrapHttpFunction(handler)); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'my-cloud-run-service', + attributes: expect.objectContaining({ [FAAS_NAME]: 'my-cloud-run-service' }), + }), + expect.any(Function), + ); + }); + + test('falls back to the static span name when no function name is resolvable', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', ''); + + const handler: HttpFunction = (_req, res) => { + res.end(); + }; + await handleHttp(wrapHttpFunction(handler)); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, + attributes: expect.objectContaining({ [FAAS_NAME]: undefined }), + }), + expect.any(Function), + ); + }); + + test('keeps the raw path out of the span name', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + + const handler: HttpFunction = (_req, res) => { + res.end(); + }; + await handleHttp(wrapHttpFunction(handler)); + + const spanName = mockStartSpanManual.mock.calls[0]?.[0]?.name; + expect(spanName).not.toContain('/path'); + }); + + test('keeps naming the span after method and path when span streaming is disabled', async () => { + vi.stubEnv('FUNCTION_TARGET', 'myCloudFunction'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'static' }), + } as unknown as Client); + + const handler: HttpFunction = (_req, res) => { + res.end(); + }; + await handleHttp(wrapHttpFunction(handler)); + + expect(mockStartSpanManual).toBeCalledWith( + expect.objectContaining({ + name: 'POST /path', + attributes: expect.objectContaining({ [FAAS_NAME]: 'myCloudFunction' }), + }), + expect.any(Function), + ); + }); + }); + // This tests that the necessary pieces are in place for request data to get added to event - the `RequestData` // integration is included in the defaults and the necessary data is stored in `sdkProcessingMetadata`. The // integration's tests cover testing that it uses that data correctly. From c9a14ed6fa1280923283c0f1afd6c1a5fda0ca91 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 10:42:36 +0200 Subject: [PATCH 2/5] add migration entry --- MIGRATION.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MIGRATION.md b/MIGRATION.md index 36b80b797d53..7eeaa2aa03e8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -971,6 +971,7 @@ The following span names were adjusted: | `http.client`, `http.client.stream` | The request method and sanitized URL | `GET https://api.example.com/users/123` | The request method and the domain, or just the method if there is no domain | `GET api.example.com`, `GET` | | `router` | Framework-specific, sometimes containing the raw URL | `/users/123`, `SvelteKit Route Change` | The span's `http.route`, or `Router` if the SDK has none | `/users/:id`, `Router` | | `handler` | Framework-specific, often carrying the request method | `GET /users/:id`, `route-handler`, `getUser` | The span's `http.route`, or `Request handler` if the SDK has none | `/users/:id`, `Request handler` | +| `function.gcp` | The request method and path for HTTP functions, otherwise the trigger's event type | `POST /users`, `google.pubsub.topic.publish` | The function name, or `Serverless function execution` if the SDK cannot resolve one | `myFunction`, `Serverless function execution` | | `graphql` | The graphql phase and, for operations, the operation name | `query GetUser`, `graphql.parse`, `graphql.resolve user.0.name` | The operation type, or the processing type where there is none | `GraphQL query`, `GraphQL parse`, `GraphQL resolve` | | `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing | `chat gpt-4`, `chat unknown` | `{operation} {model}`, or `{operation}` if the model is missing | `chat gpt-4`, `chat` | | `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation | `chain format_prompt`, `chain unknown_chain` | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order, or `{operation}` if the span carries none | `invoke_agent format_prompt`, `invoke_agent` | @@ -986,6 +987,8 @@ The following span names were adjusted: | `db` (supabase) | The query builder call and the table, or `auth ` for auth calls | `select(...) from(users)`, `auth signInWithPassword` | The operation and the table, or the dotted auth method | `select users`, `auth.signInWithPassword` | | `db.query` (redis, ioredis) | The serialized command, with its arguments redacted, or `redis-` on the diagnostics-channel path | `set test-key [1 other arguments]`, `redis-SET` | The operation and the connection, the operation and the redis function for `FCALL`/`FCALL_RO`, or `redis` when the SDK knows neither | `SET localhost:6379`, `fcall my_func`, `redis` | +GCP function spans additionally carry `faas.name`, the `gcp.function.context.*` fields of the trigger event, and `http.request.method` plus `url.path` for HTTP functions. + #### Filtering and sampling When span streaming is enabled (i.e. by default) `ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name: From 07ddbd6e09959a78f87949ed27f568a5a26a2911 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 10:48:38 +0200 Subject: [PATCH 3/5] cleanup --- packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts b/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts index eb1002be9532..e7b1f6c7e21b 100644 --- a/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts +++ b/packages/google-cloud-serverless/src/gcpfunction/cloud_events.ts @@ -65,7 +65,6 @@ function _wrapCloudEventFunction( [FAAS_TRIGGER]: 'cloud_event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_cloud_event', - // not yet in conventions but this attribute will also determine the span description [GCP_FUNCTION_CONTEXT_TYPE]: context.type, [GCP_FUNCTION_CONTEXT_ID]: context.id, [GCP_FUNCTION_CONTEXT_SOURCE]: context.source, From e4d2ceddc323ff3e3594048eb1fce10df7f21159 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 4 Sep 2026 14:11:11 +0200 Subject: [PATCH 4/5] fix source when streaming --- .../google-cloud-serverless/src/gcpfunction/http.ts | 12 +++++------- .../test/gcpfunction/http.test.ts | 13 ++++++++++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/google-cloud-serverless/src/gcpfunction/http.ts b/packages/google-cloud-serverless/src/gcpfunction/http.ts index 9c39ec940327..a90cb8ad00fd 100644 --- a/packages/google-cloud-serverless/src/gcpfunction/http.ts +++ b/packages/google-cloud-serverless/src/gcpfunction/http.ts @@ -59,13 +59,11 @@ function _wrapHttpFunction(fn: HttpFunction, options: Partial): const normalizedRequest = httpRequestToRequestData(req); getCurrentScope().setSDKProcessingMetadata({ normalizedRequest }); - const client = getClient(); - const functionName = getFunctionName(); - const name = - client && hasSpanStreamingEnabled(client) - ? functionName || SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK - : `${reqMethod} ${reqUrl}`; + + const client = getClient(); + const hasSpanStreaming = client && hasSpanStreamingEnabled(client); + const name = hasSpanStreaming ? functionName || SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK : `${reqMethod} ${reqUrl}`; return startSpanManual( { @@ -74,7 +72,7 @@ function _wrapHttpFunction(fn: HttpFunction, options: Partial): [SENTRY_OP]: FUNCTION_GCP, [FAAS_NAME]: functionName, [FAAS_TRIGGER]: 'http', - [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SENTRY_SEGMENT_NAME_SOURCE]: hasSpanStreaming ? 'component' : 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_http', // The method and path used to be the span name; they stay on the span so that // information survives the low-cardinality rename. diff --git a/packages/google-cloud-serverless/test/gcpfunction/http.test.ts b/packages/google-cloud-serverless/test/gcpfunction/http.test.ts index d852061b7c20..881fc6867bd6 100644 --- a/packages/google-cloud-serverless/test/gcpfunction/http.test.ts +++ b/packages/google-cloud-serverless/test/gcpfunction/http.test.ts @@ -215,6 +215,7 @@ describe('GCPFunction', () => { // The method and path stay on the span even though they are no longer the name. [HTTP_REQUEST_METHOD]: 'POST', [URL_PATH]: '/path', + [SENTRY_SEGMENT_NAME_SOURCE]: 'component', }), }), expect.any(Function), @@ -233,7 +234,10 @@ describe('GCPFunction', () => { expect(mockStartSpanManual).toBeCalledWith( expect.objectContaining({ name: 'my-cloud-run-service', - attributes: expect.objectContaining({ [FAAS_NAME]: 'my-cloud-run-service' }), + attributes: expect.objectContaining({ + [FAAS_NAME]: 'my-cloud-run-service', + [SENTRY_SEGMENT_NAME_SOURCE]: 'component', + }), }), expect.any(Function), ); @@ -251,7 +255,7 @@ describe('GCPFunction', () => { expect(mockStartSpanManual).toBeCalledWith( expect.objectContaining({ name: SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, - attributes: expect.objectContaining({ [FAAS_NAME]: undefined }), + attributes: expect.objectContaining({ [FAAS_NAME]: undefined, [SENTRY_SEGMENT_NAME_SOURCE]: 'component' }), }), expect.any(Function), ); @@ -283,7 +287,10 @@ describe('GCPFunction', () => { expect(mockStartSpanManual).toBeCalledWith( expect.objectContaining({ name: 'POST /path', - attributes: expect.objectContaining({ [FAAS_NAME]: 'myCloudFunction' }), + attributes: expect.objectContaining({ + [FAAS_NAME]: 'myCloudFunction', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + }), }), expect.any(Function), ); From 43dfada42b5d1e5eb56a93677486feb015bde217 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 8 Sep 2026 16:22:24 +0200 Subject: [PATCH 5/5] review suggestions --- packages/google-cloud-serverless/src/gcpfunction/events.ts | 5 ++--- .../test/gcpfunction/cloud_event.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/google-cloud-serverless/src/gcpfunction/events.ts b/packages/google-cloud-serverless/src/gcpfunction/events.ts index eb48221843ef..c15023b68369 100644 --- a/packages/google-cloud-serverless/src/gcpfunction/events.ts +++ b/packages/google-cloud-serverless/src/gcpfunction/events.ts @@ -7,6 +7,7 @@ import { GCP_FUNCTION_CONTEXT_EVENT_ID, GCP_FUNCTION_CONTEXT_RESOURCE, GCP_FUNCTION_CONTEXT_TIMESTAMP, + SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { FUNCTION_GCP } from '@sentry/conventions/op'; import { @@ -14,7 +15,6 @@ import { getClient, handleCallbackErrors, hasSpanStreamingEnabled, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, } from '@sentry/core'; import { captureException, flush, getCurrentScope, startSpanManual } from '@sentry/node'; @@ -66,8 +66,7 @@ function _wrapEventFunction [FAAS_NAME]: functionName, [FAAS_TRIGGER]: 'event', [SENTRY_SEGMENT_NAME_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', - // not yet in conventions but this attribute will also determine the span description + [SENTRY_ORIGIN]: 'auto.function.serverless.gcp_event', [GCP_FUNCTION_CONTEXT_EVENT_TYPE]: context.eventType, [GCP_FUNCTION_CONTEXT_EVENT_ID]: context.eventId, [GCP_FUNCTION_CONTEXT_RESOURCE]: context.resource, diff --git a/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts b/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts index 8275cdb4dd20..514a288f9531 100644 --- a/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts +++ b/packages/google-cloud-serverless/test/gcpfunction/cloud_event.test.ts @@ -312,7 +312,7 @@ describe('wrapCloudEventFunction', () => { const wrappedHandler = wrapCloudEventFunction(handler); await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); - const expectedStartSapanOptions = { + const expectedStartSpanOptions = { name: 'google.cloud.pubsub.topic.v1.messagePublished', attributes: { [SENTRY_OP]: FUNCTION_GCP, @@ -328,7 +328,7 @@ describe('wrapCloudEventFunction', () => { }, }; - expect(mockStartSpanManual).toBeCalledWith(expectedStartSapanOptions, expect.any(Function)); + expect(mockStartSpanManual).toBeCalledWith(expectedStartSpanOptions, expect.any(Function)); expect(mockCaptureException).toBeCalledWith(error, expect.any(Function)); const scopeFunction = mockCaptureException.mock.calls[0][1];