>) => {
+export const meta = ({
+ data,
+}: {
+ data: {
+ ENV: {SENTRY_DSN: string};
+ sentryTrace: string;
+ sentryBaggage: string;
+ };
+}) => {
return [
+ {
+ env: data.ENV,
+ },
{
name: 'sentry-trace',
content: data.sentryTrace,
@@ -187,11 +219,14 @@ export function ErrorBoundary() {
* );
* ```
* */
-async function validateCustomerAccessToken(session: HydrogenSession, customerAccessToken?: CustomerAccessToken) {
+async function validateCustomerAccessToken(
+ session: HydrogenSession,
+ customerAccessToken?: CustomerAccessToken,
+) {
let isLoggedIn = false;
const headers = new Headers();
if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) {
- return { isLoggedIn, headers };
+ return {isLoggedIn, headers};
}
const expiresAt = new Date(customerAccessToken.expiresAt).getTime();
@@ -205,7 +240,7 @@ async function validateCustomerAccessToken(session: HydrogenSession, customerAcc
isLoggedIn = true;
}
- return { isLoggedIn, headers };
+ return {isLoggedIn, headers};
}
const MENU_FRAGMENT = `#graphql
diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx
index 48e9494f1c2c..19fa119ec2a4 100644
--- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx
@@ -1,6 +1,12 @@
import { Link, useSearchParams } from '@remix-run/react';
import * as Sentry from '@sentry/remix/cloudflare';
+declare global {
+ interface Window {
+ capturedExceptionId?: string;
+ }
+}
+
export default function Index() {
const [searchParams] = useSearchParams();
diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx
index 7fe190a6eb77..7f5f79028c00 100644
--- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx
@@ -10,7 +10,7 @@ export const loader: LoaderFunction = async ({ params: { id } }) => {
};
export default function LoaderError() {
- const data = useLoaderData();
+ const data = useLoaderData() as { test?: string };
return (
diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json
index 6890e46da127..b2956b59aa52 100644
--- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json
@@ -3,11 +3,11 @@
"sideEffects": false,
"type": "module",
"scripts": {
- "build": "shopify hydrogen build --codegen",
+ "build": "pnpm typecheck && shopify hydrogen build --codegen",
"dev": "shopify hydrogen dev --codegen",
"preview": "shopify hydrogen preview",
"lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
- "typecheck": "tsc --noEmit",
+ "typecheck": "tsc",
"codegen": "shopify hydrogen codegen",
"clean": "npx rimraf node_modules dist pnpm-lock.yaml",
"test:build": "pnpm install && npx playwright install && pnpm build",
@@ -17,6 +17,7 @@
"dependencies": {
"@remix-run/react": "^2.15.2",
"@remix-run/server-runtime": "^2.15.2",
+ "@remix-run/cloudflare-pages": "^2.15.2",
"@sentry/cloudflare": "latest || *",
"@sentry/remix": "latest || *",
"@sentry/vite-plugin": "^3.1.2",
diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts
index 6a3a889cf968..372e584e0a9e 100644
--- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts
@@ -12,11 +12,25 @@ import { type AppLoadContext, createRequestHandler, getStorefrontHeaders } from
import { CART_QUERY_FRAGMENT } from '~/lib/fragments';
import { AppSession } from '~/lib/session';
// Virtual entry point for the app
+// Typescript errors about the type of `remixBuild` will be there when it's used
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
import * as remixBuild from 'virtual:remix/server-build';
/**
* Export a fetch handler in module format.
*/
+type Env = {
+ SESSION_SECRET: string;
+ PUBLIC_STOREFRONT_API_TOKEN: string;
+ PRIVATE_STOREFRONT_API_TOKEN: string;
+ PUBLIC_STORE_DOMAIN: string;
+ PUBLIC_STOREFRONT_ID: string;
+ PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string;
+ PUBLIC_CUSTOMER_ACCOUNT_API_URL: string;
+ // Add any other environment variables your app expects here
+};
+
export default {
async fetch(request: Request, env: Env, executionContext: ExecutionContext): Promise {
return wrapRequestHandler(
@@ -68,7 +82,7 @@ export default {
request,
session,
customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID,
- customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL,
+ shopId: env.PUBLIC_STORE_DOMAIN,
});
/*
diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json
index dcd7c7237a90..37083f3cea29 100644
--- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json
@@ -1,5 +1,10 @@
{
- "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
+ "include": [
+ "server.ts",
+ "./app/**/*.d.ts",
+ "./app/**/*.ts",
+ "./app/**/*.tsx"
+ ],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"isolatedModules": true,
diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts
index 80eb1a166e9b..cfb66b372420 100644
--- a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts
+++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts
@@ -17,9 +17,14 @@ test('Sends server-side Supabase auth admin `createUser` span', async ({ page, b
const transactionEvent = await httpTransactionPromise;
expect(transactionEvent.spans).toContainEqual({
- data: expect.any(Object),
- description: 'createUser',
- op: 'db.auth.admin.createUser',
+ data: expect.objectContaining({
+ 'db.operation': 'auth.admin.createUser',
+ 'db.system': 'postgresql',
+ 'sentry.op': 'db',
+ 'sentry.origin': 'auto.db.supabase',
+ }),
+ description: 'auth (admin) createUser',
+ op: 'db',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
@@ -54,8 +59,15 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry',
expect(transactionEvent.spans).toContainEqual(
expect.objectContaining({
- description: 'from(todos)',
- op: 'db.select',
+ description: 'select(*) filter(order, asc) from(todos)',
+ op: 'db',
+ data: expect.objectContaining({
+ 'db.operation': 'select',
+ 'db.query': ['select(*)', 'filter(order, asc)'],
+ 'db.system': 'postgresql',
+ 'sentry.op': 'db',
+ 'sentry.origin': 'auto.db.supabase',
+ }),
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
@@ -67,9 +79,15 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry',
);
expect(transactionEvent.spans).toContainEqual({
- data: expect.any(Object),
- description: 'from(todos)',
- op: 'db.insert',
+ data: expect.objectContaining({
+ 'db.operation': 'select',
+ 'db.query': ['select(*)', 'filter(order, asc)'],
+ 'db.system': 'postgresql',
+ 'sentry.op': 'db',
+ 'sentry.origin': 'auto.db.supabase',
+ }),
+ description: 'select(*) filter(order, asc) from(todos)',
+ op: 'db',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
@@ -83,7 +101,7 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry',
timestamp: expect.any(Number),
type: 'supabase',
category: 'db.select',
- message: 'from(todos)',
+ message: 'select(*) filter(order, asc) from(todos)',
data: expect.any(Object),
});
@@ -91,7 +109,7 @@ test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry',
timestamp: expect.any(Number),
type: 'supabase',
category: 'db.insert',
- message: 'from(todos)',
+ message: 'insert(...) select(*) from(todos)',
data: expect.any(Object),
});
});
@@ -109,8 +127,15 @@ test('Sends server-side Supabase db-operation spans and breadcrumbs to Sentry',
expect(transactionEvent.spans).toContainEqual(
expect.objectContaining({
- description: 'from(todos)',
- op: 'db.select',
+ data: expect.objectContaining({
+ 'db.operation': 'insert',
+ 'db.query': ['select(*)'],
+ 'db.system': 'postgresql',
+ 'sentry.op': 'db',
+ 'sentry.origin': 'auto.db.supabase',
+ }),
+ description: 'insert(...) select(*) from(todos)',
+ op: 'db',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
@@ -122,9 +147,15 @@ test('Sends server-side Supabase db-operation spans and breadcrumbs to Sentry',
);
expect(transactionEvent.spans).toContainEqual({
- data: expect.any(Object),
- description: 'from(todos)',
- op: 'db.insert',
+ data: expect.objectContaining({
+ 'db.operation': 'select',
+ 'db.query': ['select(*)'],
+ 'db.system': 'postgresql',
+ 'sentry.op': 'db',
+ 'sentry.origin': 'auto.db.supabase',
+ }),
+ description: 'select(*) from(todos)',
+ op: 'db',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
@@ -138,7 +169,7 @@ test('Sends server-side Supabase db-operation spans and breadcrumbs to Sentry',
timestamp: expect.any(Number),
type: 'supabase',
category: 'db.select',
- message: 'from(todos)',
+ message: 'select(*) from(todos)',
data: expect.any(Object),
});
@@ -146,7 +177,7 @@ test('Sends server-side Supabase db-operation spans and breadcrumbs to Sentry',
timestamp: expect.any(Number),
type: 'supabase',
category: 'db.insert',
- message: 'from(todos)',
+ message: 'insert(...) select(*) from(todos)',
data: expect.any(Object),
});
});
@@ -154,8 +185,7 @@ test('Sends server-side Supabase db-operation spans and breadcrumbs to Sentry',
test('Sends server-side Supabase auth admin `listUsers` span', async ({ page, baseURL }) => {
const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => {
return (
- transactionEvent?.contexts?.trace?.op === 'http.server' &&
- transactionEvent?.transaction === 'GET /api/list-users'
+ transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /api/list-users'
);
});
@@ -163,9 +193,14 @@ test('Sends server-side Supabase auth admin `listUsers` span', async ({ page, ba
const transactionEvent = await httpTransactionPromise;
expect(transactionEvent.spans).toContainEqual({
- data: expect.any(Object),
- description: 'listUsers',
- op: 'db.auth.admin.listUsers',
+ data: expect.objectContaining({
+ 'db.operation': 'auth.admin.listUsers',
+ 'db.system': 'postgresql',
+ 'sentry.op': 'db',
+ 'sentry.origin': 'auto.db.supabase',
+ }),
+ description: 'auth (admin) listUsers',
+ op: 'db',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
diff --git a/packages/angular/package.json b/packages/angular/package.json
index d5a028acf088..8664b6425636 100644
--- a/packages/angular/package.json
+++ b/packages/angular/package.json
@@ -15,9 +15,9 @@
"access": "public"
},
"peerDependencies": {
- "@angular/common": ">= 14.x <= 19.x",
- "@angular/core": ">= 14.x <= 19.x",
- "@angular/router": ">= 14.x <= 19.x",
+ "@angular/common": ">= 14.x <= 20.x",
+ "@angular/core": ">= 14.x <= 20.x",
+ "@angular/router": ">= 14.x <= 20.x",
"rxjs": "^6.5.5 || ^7.x"
},
"dependencies": {
diff --git a/packages/browser/src/integrations/browserapierrors.ts b/packages/browser/src/integrations/browserapierrors.ts
index 55e716fd8627..113c969b9ebc 100644
--- a/packages/browser/src/integrations/browserapierrors.ts
+++ b/packages/browser/src/integrations/browserapierrors.ts
@@ -46,6 +46,15 @@ interface BrowserApiErrorsOptions {
requestAnimationFrame: boolean;
XMLHttpRequest: boolean;
eventTarget: boolean | string[];
+
+ /**
+ * If you experience issues with this integration causing double-invocations of event listeners,
+ * try setting this option to `true`. It will unregister the original callbacks from the event targets
+ * before adding the instrumented callback.
+ *
+ * @default false
+ */
+ unregisterOriginalCallbacks: boolean;
}
const _browserApiErrorsIntegration = ((options: Partial = {}) => {
@@ -55,6 +64,7 @@ const _browserApiErrorsIntegration = ((options: Partial
requestAnimationFrame: true,
setInterval: true,
setTimeout: true,
+ unregisterOriginalCallbacks: false,
...options,
};
@@ -82,7 +92,7 @@ const _browserApiErrorsIntegration = ((options: Partial
const eventTargetOption = _options.eventTarget;
if (eventTargetOption) {
const eventTarget = Array.isArray(eventTargetOption) ? eventTargetOption : DEFAULT_EVENT_TARGET;
- eventTarget.forEach(_wrapEventTarget);
+ eventTarget.forEach(target => _wrapEventTarget(target, _options));
}
},
};
@@ -160,7 +170,7 @@ function _wrapXHR(originalSend: () => void): () => void {
};
}
-function _wrapEventTarget(target: string): void {
+function _wrapEventTarget(target: string, integrationOptions: BrowserApiErrorsOptions): void {
const globalObject = WINDOW as unknown as Record;
const proto = globalObject[target]?.prototype;
@@ -197,6 +207,10 @@ function _wrapEventTarget(target: string): void {
// can sometimes get 'Permission denied to access property "handle Event'
}
+ if (integrationOptions.unregisterOriginalCallbacks) {
+ unregisterOriginalCallback(this, eventName, fn);
+ }
+
return original.apply(this, [
eventName,
wrap(fn, {
@@ -253,3 +267,14 @@ function _wrapEventTarget(target: string): void {
function isEventListenerObject(obj: unknown): obj is EventListenerObject {
return typeof (obj as EventListenerObject).handleEvent === 'function';
}
+
+function unregisterOriginalCallback(target: unknown, eventName: string, fn: EventListenerOrEventListenerObject): void {
+ if (
+ target &&
+ typeof target === 'object' &&
+ 'removeEventListener' in target &&
+ typeof target.removeEventListener === 'function'
+ ) {
+ target.removeEventListener(eventName, fn);
+ }
+}
diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts
index 63229ccbdcf4..084d50356a83 100644
--- a/packages/core/src/integrations/supabase.ts
+++ b/packages/core/src/integrations/supabase.ts
@@ -219,10 +219,12 @@ function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): A
apply(target, thisArg, argumentsList) {
return startSpan(
{
- name: operation.name,
+ name: `auth ${isAdmin ? '(admin) ' : ''}${operation.name}`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase',
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `db.auth.${isAdmin ? 'admin.' : ''}${operation.name}`,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',
+ 'db.system': 'postgresql',
+ 'db.operation': `auth.${isAdmin ? 'admin.' : ''}${operation.name}`,
},
},
span => {
@@ -341,7 +343,6 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
const pathParts = typedThis.url.pathname.split('/');
const table = pathParts.length > 0 ? pathParts[pathParts.length - 1] : '';
- const description = `from(${table})`;
const queryItems: string[] = [];
for (const [key, value] of typedThis.url.searchParams.entries()) {
@@ -349,7 +350,6 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
// so we need to use array instead of object to collect them.
queryItems.push(translateFiltersIntoMethods(key, value));
}
-
const body: Record = Object.create(null);
if (isPlainObject(typedThis.body)) {
for (const [key, value] of Object.entries(typedThis.body)) {
@@ -357,14 +357,22 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
}
}
+ // Adding operation to the beginning of the description if it's not a `select` operation
+ // For example, it can be an `insert` or `update` operation but the query can be `select(...)`
+ // For `select` operations, we don't need repeat it in the description
+ const description = `${operation === 'select' ? '' : `${operation}${body ? '(...) ' : ''}`}${queryItems.join(
+ ' ',
+ )} from(${table})`;
+
const attributes: Record = {
'db.table': table,
'db.schema': typedThis.schema,
'db.url': typedThis.url.origin,
'db.sdk': typedThis.headers['X-Client-Info'],
'db.system': 'postgresql',
+ 'db.operation': operation,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase',
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `db.${operation}`,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',
};
if (queryItems.length) {
diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts
index 9a738d503a80..f44817c13715 100644
--- a/packages/core/src/logs/exports.ts
+++ b/packages/core/src/logs/exports.ts
@@ -1,8 +1,10 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
-import { getClient, getCurrentScope } from '../currentScopes';
+import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
+import type { Scope, ScopeData } from '../scope';
import type { Log, SerializedLog, SerializedLogAttributeValue } from '../types-hoist/log';
+import { mergeScopeData } from '../utils/applyScopeDataToEvent';
import { _getSpanForScope } from '../utils/spanOnScope';
import { isParameterizedString } from '../utils-hoist/is';
import { logger } from '../utils-hoist/logger';
@@ -61,6 +63,25 @@ export function logAttributeToSerializedLogAttribute(value: unknown): Serialized
}
}
+/**
+ * Sets a log attribute if the value exists and the attribute key is not already present.
+ *
+ * @param logAttributes - The log attributes object to modify.
+ * @param key - The attribute key to set.
+ * @param value - The value to set (only sets if truthy and key not present).
+ * @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true.
+ */
+function setLogAttribute(
+ logAttributes: Record,
+ key: string,
+ value: unknown,
+ setEvenIfPresent = true,
+): void {
+ if (value && (!logAttributes[key] || setEvenIfPresent)) {
+ logAttributes[key] = value;
+ }
+}
+
/**
* Captures a serialized log event and adds it to the log buffer for the given client.
*
@@ -96,7 +117,7 @@ export function _INTERNAL_captureSerializedLog(client: Client, serializedLog: Se
export function _INTERNAL_captureLog(
beforeLog: Log,
client: Client | undefined = getClient(),
- scope = getCurrentScope(),
+ currentScope = getCurrentScope(),
captureSerializedLog: (client: Client, log: SerializedLog) => void = _INTERNAL_captureSerializedLog,
): void {
if (!client) {
@@ -111,25 +132,27 @@ export function _INTERNAL_captureLog(
return;
}
- const [, traceContext] = _getTraceInfoFromScope(client, scope);
+ const [, traceContext] = _getTraceInfoFromScope(client, currentScope);
const processedLogAttributes = {
...beforeLog.attributes,
};
- if (release) {
- processedLogAttributes['sentry.release'] = release;
+ const { user } = getMergedScopeData(currentScope);
+ // Only attach user to log attributes if sendDefaultPii is enabled
+ if (client.getOptions().sendDefaultPii) {
+ const { id, email, username } = user;
+ setLogAttribute(processedLogAttributes, 'user.id', id, false);
+ setLogAttribute(processedLogAttributes, 'user.email', email, false);
+ setLogAttribute(processedLogAttributes, 'user.name', username, false);
}
- if (environment) {
- processedLogAttributes['sentry.environment'] = environment;
- }
+ setLogAttribute(processedLogAttributes, 'sentry.release', release);
+ setLogAttribute(processedLogAttributes, 'sentry.environment', environment);
- const { sdk } = client.getSdkMetadata() ?? {};
- if (sdk) {
- processedLogAttributes['sentry.sdk.name'] = sdk.name;
- processedLogAttributes['sentry.sdk.version'] = sdk.version;
- }
+ const { name, version } = client.getSdkMetadata()?.sdk ?? {};
+ setLogAttribute(processedLogAttributes, 'sentry.sdk.name', name);
+ setLogAttribute(processedLogAttributes, 'sentry.sdk.version', version);
const beforeLogMessage = beforeLog.message;
if (isParameterizedString(beforeLogMessage)) {
@@ -140,11 +163,9 @@ export function _INTERNAL_captureLog(
});
}
- const span = _getSpanForScope(scope);
- if (span) {
- // Add the parent span ID to the log attributes for trace context
- processedLogAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
- }
+ const span = _getSpanForScope(currentScope);
+ // Add the parent span ID to the log attributes for trace context
+ setLogAttribute(processedLogAttributes, 'sentry.trace.parent_span_id', span?.spanContext().spanId);
const processedLog = { ...beforeLog, attributes: processedLogAttributes };
@@ -218,3 +239,17 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array
export function _INTERNAL_getLogBuffer(client: Client): Array | undefined {
return GLOBAL_OBJ._sentryClientToLogBufferMap?.get(client);
}
+
+/**
+ * Get the scope data for the current scope after merging with the
+ * global scope and isolation scope.
+ *
+ * @param currentScope - The current scope.
+ * @returns The scope data.
+ */
+function getMergedScopeData(currentScope: Scope): ScopeData {
+ const scopeData = getGlobalScope().getScopeData();
+ mergeScopeData(scopeData, getIsolationScope().getScopeData());
+ mergeScopeData(scopeData, currentScope.getScopeData());
+ return scopeData;
+}
diff --git a/packages/core/test/lib/logs/exports.test.ts b/packages/core/test/lib/logs/exports.test.ts
index 1ae570bc5968..8c1fe4d8e76f 100644
--- a/packages/core/test/lib/logs/exports.test.ts
+++ b/packages/core/test/lib/logs/exports.test.ts
@@ -375,4 +375,362 @@ describe('_INTERNAL_captureLog', () => {
expect(beforeCaptureLogSpy).toHaveBeenCalledWith('afterCaptureLog', log);
beforeCaptureLogSpy.mockRestore();
});
+
+ describe('user functionality', () => {
+ it('includes user data in log attributes when sendDefaultPii is enabled', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ username: 'testuser',
+ });
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with user' }, client, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'user.id': {
+ value: '123',
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ 'user.name': {
+ value: 'testuser',
+ type: 'string',
+ },
+ });
+ });
+
+ it('does not include user data in log attributes when sendDefaultPii is disabled', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: false,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ username: 'testuser',
+ });
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log without user' }, client, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({});
+ });
+
+ it('includes partial user data when only some fields are available', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ id: '123',
+ // email and username are missing
+ });
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with partial user' }, client, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'user.id': {
+ value: '123',
+ type: 'string',
+ },
+ });
+ });
+
+ it('includes user email and username without id', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ email: 'user@example.com',
+ username: 'testuser',
+ // id is missing
+ });
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with email and username' }, client, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ 'user.name': {
+ value: 'testuser',
+ type: 'string',
+ },
+ });
+ });
+
+ it('does not include user data when user object is empty', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({});
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with empty user' }, client, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({});
+ });
+
+ it('combines user data with other log attributes', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ release: '1.0.0',
+ environment: 'test',
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ });
+
+ _INTERNAL_captureLog(
+ {
+ level: 'info',
+ message: 'test log with user and other attributes',
+ attributes: { component: 'auth', action: 'login' },
+ },
+ client,
+ scope,
+ );
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ component: {
+ value: 'auth',
+ type: 'string',
+ },
+ action: {
+ value: 'login',
+ type: 'string',
+ },
+ 'user.id': {
+ value: '123',
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: '1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'test',
+ type: 'string',
+ },
+ });
+ });
+
+ it('handles user data with non-string values', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ id: 123, // number instead of string
+ email: 'user@example.com',
+ username: undefined, // undefined value
+ });
+
+ _INTERNAL_captureLog({ level: 'info', message: 'test log with non-string user values' }, client, scope);
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'user.id': {
+ value: 123,
+ type: 'integer',
+ },
+ 'user.email': {
+ value: 'user@example.com',
+ type: 'string',
+ },
+ });
+ });
+
+ it('preserves existing user attributes in log and does not override them', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ id: '123',
+ email: 'user@example.com',
+ });
+
+ _INTERNAL_captureLog(
+ {
+ level: 'info',
+ message: 'test log with existing user attributes',
+ attributes: {
+ 'user.id': 'existing-id', // This should NOT be overridden by scope user
+ 'user.custom': 'custom-value', // This should be preserved
+ },
+ },
+ client,
+ scope,
+ );
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'user.custom': {
+ value: 'custom-value',
+ type: 'string',
+ },
+ 'user.id': {
+ value: 'existing-id', // Existing value is preserved
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'user@example.com', // Only added because user.email wasn't already present
+ type: 'string',
+ },
+ });
+ });
+
+ it('only adds scope user data for attributes that do not already exist', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ sendDefaultPii: true,
+ });
+ const client = new TestClient(options);
+ const scope = new Scope();
+ scope.setUser({
+ id: 'scope-id',
+ email: 'scope@example.com',
+ username: 'scope-user',
+ });
+
+ _INTERNAL_captureLog(
+ {
+ level: 'info',
+ message: 'test log with partial existing user attributes',
+ attributes: {
+ 'user.email': 'existing@example.com', // This should be preserved
+ 'other.attr': 'value',
+ },
+ },
+ client,
+ scope,
+ );
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'other.attr': {
+ value: 'value',
+ type: 'string',
+ },
+ 'user.email': {
+ value: 'existing@example.com', // Existing email is preserved
+ type: 'string',
+ },
+ 'user.id': {
+ value: 'scope-id', // Added from scope because not present
+ type: 'string',
+ },
+ 'user.name': {
+ value: 'scope-user', // Added from scope because not present
+ type: 'string',
+ },
+ });
+ });
+ });
+
+ it('overrides user-provided system attributes with SDK values', () => {
+ const options = getDefaultTestClientOptions({
+ dsn: PUBLIC_DSN,
+ _experiments: { enableLogs: true },
+ release: 'sdk-release-1.0.0',
+ environment: 'sdk-environment',
+ });
+ const client = new TestClient(options);
+
+ // Mock getSdkMetadata to return SDK info
+ vi.spyOn(client, 'getSdkMetadata').mockReturnValue({
+ sdk: {
+ name: 'sentry.javascript.node',
+ version: '7.0.0',
+ },
+ });
+
+ const scope = new Scope();
+
+ _INTERNAL_captureLog(
+ {
+ level: 'info',
+ message: 'test log with user-provided system attributes',
+ attributes: {
+ 'sentry.release': 'user-release-2.0.0',
+ 'sentry.environment': 'user-environment',
+ 'sentry.sdk.name': 'user-sdk-name',
+ 'sentry.sdk.version': 'user-sdk-version',
+ 'user.custom': 'preserved-value',
+ },
+ },
+ client,
+ scope,
+ );
+
+ const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
+ expect(logAttributes).toEqual({
+ 'user.custom': {
+ value: 'preserved-value',
+ type: 'string',
+ },
+ 'sentry.release': {
+ value: 'sdk-release-1.0.0',
+ type: 'string',
+ },
+ 'sentry.environment': {
+ value: 'sdk-environment',
+ type: 'string',
+ },
+ 'sentry.sdk.name': {
+ value: 'sentry.javascript.node',
+ type: 'string',
+ },
+ 'sentry.sdk.version': {
+ value: '7.0.0',
+ type: 'string',
+ },
+ });
+ });
});