diff --git a/MIGRATION.md b/MIGRATION.md index 8e3990f3f735..a925ba77ef22 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -955,32 +955,42 @@ Two things hold throughout this section: The following span names were adjusted: -| Span op | Before | Example | After | Example | -| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Pageload` if the SDK has none | `/users/:id`, `Pageload` | -| `navigation`, `navigation.redirect` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Navigation` if the SDK has none | `/users/:id`, `Navigation` | -| `resource.*` | The resource URL, relative to the page origin for same-origin resources | `/assets/app.js` | The resource domain, or `Resource` if the SDK has none | `cdn.example.com`, `Resource` | -| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one | `GET /users/:id`, `GET /users/123` | The request method and route when one is known, otherwise just the method | `GET /users/:id`, `GET` | -| `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` | -| `mcp.server` | The method and its target, including the resource URI | `resources/read file:///docs/api.md`, `tools/call get-weather` | The method alone for resource methods. Tool and prompt names are unchanged | `resources/read`, `tools/call get-weather` | -| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name | `notifications/tools/list_changed` | The notification method name, or `MCP notification` if the message carries none | `notifications/tools/list_changed`, `MCP notification` | -| `queue.publish` | Integration-specific | `publish my-exchange`, `send my-topic` | The messaging operation type and the destination, or just the operation type when the destination has no name | `send my-exchange`, `send` | -| `queue.process` | Integration-specific, sometimes containing per-message data | `my-queue process`, `order.created.12345 process` | The messaging operation type and the destination, or just the operation type when the destination has no name | `process my-queue`, `process` | -| `queue.receive` | The kafkajs operation name | `poll my-topic` | The messaging operation type and the destination | `receive my-topic` | -| `cache.*` | The cache key(s), or for dataloader the operation and loader name | `user:123`, `dataloader.load usersLoader` | The cache operation | `cache.get`, `cache.put`, `cache.remove` | -| `db`, `db.query` (SQL) | The statement the driver ran | `SELECT * FROM "User" WHERE id = $1` | A summary of it, or, where there is no statement, the next template the driver can fill: the operation and table, the namespace, the database system, and `Database operation` last | `SELECT "User"`, `postgresql` | -| `db` (mongodb) | The serialized command, or `mongodb.` where there is none | `mongodb.find` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `find users`, `mongodb` | -| `db` (mongoose) | `mongoose..` | `mongoose.BlogPost.findOne` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `findOne blogposts` | -| `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. +| Span op | Before | Example | After | Example | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Pageload` if the SDK has none | `/users/:id`, `Pageload` | +| `navigation`, `navigation.redirect` | The parameterized route, or the raw URL path if the SDK couldn't resolve one | `/users/:id`, `/users/123` | The parameterized route, or `Navigation` if the SDK has none | `/users/:id`, `Navigation` | +| `resource.*` | The resource URL, relative to the page origin for same-origin resources | `/assets/app.js` | The resource domain, or `Resource` if the SDK has none | `cdn.example.com`, `Resource` | +| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one | `GET /users/:id`, `GET /users/123` | The request method and route when one is known, otherwise just the method | `GET /users/:id`, `GET` | +| `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 or trigger type | `POST /users`, `google.pubsub.topic.publish`, `firebase.function.http.request` | 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` | +| `mcp.server` | The method and its target, including the resource URI | `resources/read file:///docs/api.md`, `tools/call get-weather` | The method alone for resource methods. Tool and prompt names are unchanged | `resources/read`, `tools/call get-weather` | +| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name | `notifications/tools/list_changed` | The notification method name, or `MCP notification` if the message carries none | `notifications/tools/list_changed`, `MCP notification` | +| `queue.publish` | Integration-specific | `publish my-exchange`, `send my-topic` | The messaging operation type and the destination, or just the operation type when the destination has no name | `send my-exchange`, `send` | +| `queue.process` | Integration-specific, sometimes containing per-message data | `my-queue process`, `order.created.12345 process` | The messaging operation type and the destination, or just the operation type when the destination has no name | `process my-queue`, `process` | +| `queue.receive` | The kafkajs operation name | `poll my-topic` | The messaging operation type and the destination | `receive my-topic` | +| `cache.*` | The cache key(s), or for dataloader the operation and loader name | `user:123`, `dataloader.load usersLoader` | The cache operation | `cache.get`, `cache.put`, `cache.remove` | +| `db`, `db.query` (SQL) | The statement the driver ran | `SELECT * FROM "User" WHERE id = $1` | A summary of it, or, where there is no statement, the next template the driver can fill: the operation and table, the namespace, the database system, and `Database operation` last | `SELECT "User"`, `postgresql` | +| `db` (mongodb) | The serialized command, or `mongodb.` where there is none | `mongodb.find` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `find users`, `mongodb` | +| `db` (mongoose) | `mongoose..` | `mongoose.BlogPost.findOne` | The operation and the collection, the database namespace when there is no collection, or `mongodb` when the SDK has neither | `findOne blogposts` | +| `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` | + +#### Serverless function spans + +`function.gcp` spans are named after the function, which the SDK reads from the `FUNCTION_TARGET` or +`K_SERVICE` environment variable. This covers `@sentry/google-cloud-serverless` and the firebase +functions integration in `@sentry/node`. + +Whatever the name no longer carries stays on the span as an attribute: + +- `faas.name` — the function name the span is named after. +- `gcp.function.context.*` — the fields of the trigger event, including the event type the span used to be named after. +- `http.request.method` and `url.path` — for HTTP-triggered functions, the method and path the span used to be named after. #### Filtering and sampling diff --git a/packages/server-utils/src/integrations/firebase/functions.ts b/packages/server-utils/src/integrations/firebase/functions.ts index 08d57ca40b71..c8a919ef6fa8 100644 --- a/packages/server-utils/src/integrations/firebase/functions.ts +++ b/packages/server-utils/src/integrations/firebase/functions.ts @@ -1,6 +1,7 @@ import { FAAS_NAME, FAAS_TRIGGER, + GCP_FUNCTION_CONTEXT_TYPE, SENTRY_KIND, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -10,7 +11,10 @@ import type { SpanAttributes } from '@sentry/core'; import { captureException, flush, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, SPAN_STATUS_ERROR, startSpanManual, } from '@sentry/core'; @@ -56,7 +60,9 @@ export function wrapFunctionsRegistration(data: FunctionsChannelContext, trigger function wrapHandler(handler: Handler, triggerType: string): Handler { const wrapped = async function (this: unknown, ...handlerArgs: unknown[]): Promise { - const functionName = process.env.FUNCTION_TARGET || process.env.K_SERVICE || 'unknown'; + const functionName = getFunctionName(); + + const enhancedTriggerType = `firebase.function.${triggerType}`; const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: FUNCTIONS_ORIGIN, @@ -66,6 +72,11 @@ function wrapHandler(handler: Handler, triggerType: string): Handler { [SENTRY_KIND]: 'server', [SENTRY_OP]: FUNCTION_GCP, [SENTRY_SEGMENT_NAME_SOURCE]: 'component', + + // This is a bit of "cheating" but not entirely wrong: + // by setting this attribute, the span description of a streamed span will be set to the + // static span name, which is the triggerType string. + [GCP_FUNCTION_CONTEXT_TYPE]: enhancedTriggerType, }; if (process.env.GCLOUD_PROJECT) { @@ -76,10 +87,16 @@ function wrapHandler(handler: Handler, triggerType: string): Handler { attributes['cloud.event_source'] = process.env.EVENTARC_CLOUD_EVENT_SOURCE; } + const client = getClient(); + const name = + client && hasSpanStreamingEnabled(client) + ? functionName || SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK + : enhancedTriggerType; + // `startSpanManual` keeps the span active while still allowing us to end it before flushing on error. return startSpanManual( { - name: `firebase.function.${triggerType}`, + name, attributes, }, async span => { @@ -106,3 +123,13 @@ function wrapHandler(handler: Handler, triggerType: string): Handler { (wrapped as unknown as Record)[WRAPPED] = true; return wrapped; } + +/** + * Resolves the name of the currently executing cloud function. + * + * Mirrors `getFunctionName` in `@sentry/google-cloud-serverless`: both feed `faas.name` for + * `function.gcp` spans, so the resolution order has to stay the same in the two places. + */ +function getFunctionName(): string | undefined { + return process.env.FUNCTION_TARGET || process.env.K_SERVICE || undefined; +} diff --git a/packages/server-utils/test/orchestrion/firebase.test.ts b/packages/server-utils/test/orchestrion/firebase.test.ts index ff56266897cd..d7e4f4a0259b 100644 --- a/packages/server-utils/test/orchestrion/firebase.test.ts +++ b/packages/server-utils/test/orchestrion/firebase.test.ts @@ -1,4 +1,4 @@ -import type { Span } from '@sentry/core'; +import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; import { getPortAndAddress, startFirestoreSpan } from '../../src/integrations/firebase/firestore'; @@ -115,6 +115,7 @@ describe('wrapFunctionsRegistration', () => { 'sentry.op': 'function.gcp', 'faas.trigger': 'http.request', 'faas.provider': 'firebase', + 'gcp.function.context.type': 'firebase.function.http.request', }), }), expect.any(Function), @@ -155,6 +156,89 @@ describe('wrapFunctionsRegistration', () => { expect(span.end).toHaveBeenCalledTimes(1); }); + describe('with span streaming enabled', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'stream' }), + } as unknown as Client); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('names the span after the function name from FUNCTION_TARGET', async () => { + vi.stubEnv('FUNCTION_TARGET', 'onDocumentCreate'); + + const wrapped = wrapAndGetHandler([vi.fn()], 'firestore.document.created'); + await wrapped({ some: 'event' }); + + expect(startSpanManualSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'onDocumentCreate', + attributes: expect.objectContaining({ + 'faas.name': 'onDocumentCreate', + // The trigger type stays on the span even though it is no longer the name, and the + // former span name stays on the span as what the description is derived from. + 'faas.trigger': 'firestore.document.created', + 'gcp.function.context.type': 'firebase.function.firestore.document.created', + }), + }), + expect.any(Function), + ); + }); + + it('falls back to K_SERVICE when FUNCTION_TARGET is unset', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', 'helloworld'); + + const wrapped = wrapAndGetHandler([vi.fn()], 'http.request'); + await wrapped('req', 'res'); + + expect(startSpanManualSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'helloworld', + attributes: expect.objectContaining({ 'faas.name': 'helloworld' }), + }), + expect.any(Function), + ); + }); + + it('falls back to the static span name when no function name is resolvable', async () => { + vi.stubEnv('FUNCTION_TARGET', ''); + vi.stubEnv('K_SERVICE', ''); + + const wrapped = wrapAndGetHandler([vi.fn()], 'http.request'); + await wrapped('req', 'res'); + + expect(startSpanManualSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: SentryCore.SERVERLESS_FUNCTION_SPAN_NAME_FALLBACK, + attributes: expect.objectContaining({ 'faas.name': undefined }), + }), + expect.any(Function), + ); + }); + + it('keeps naming the span after the trigger type when span streaming is disabled', async () => { + vi.stubEnv('FUNCTION_TARGET', 'onDocumentCreate'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle: 'static' }), + } as unknown as Client); + + const wrapped = wrapAndGetHandler([vi.fn()], 'firestore.document.created'); + await wrapped({ some: 'event' }); + + expect(startSpanManualSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'firebase.function.firestore.document.created', + attributes: expect.objectContaining({ 'faas.name': 'onDocumentCreate' }), + }), + expect.any(Function), + ); + }); + }); + it('does not double-wrap an already-wrapped handler', () => { const original = vi.fn(); const wrappedOnce = wrapAndGetHandler([original], 'http.request');