Skip to content

Commit ecc6cb2

Browse files
committed
feat(core): Add user to logs
1 parent 81c2ff6 commit ecc6cb2

File tree

2 files changed

+335
-4
lines changed

2 files changed

+335
-4
lines changed

packages/core/src/logs/exports.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Client } from '../client';
22
import { _getTraceInfoFromScope } from '../client';
3-
import { getClient, getCurrentScope } from '../currentScopes';
3+
import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes';
44
import { DEBUG_BUILD } from '../debug-build';
5+
import type { Scope, ScopeData } from '../scope';
56
import type { Log, SerializedLog, SerializedLogAttributeValue } from '../types-hoist/log';
7+
import { mergeScopeData } from '../utils/applyScopeDataToEvent';
68
import { _getSpanForScope } from '../utils/spanOnScope';
79
import { isParameterizedString } from '../utils-hoist/is';
810
import { logger } from '../utils-hoist/logger';
@@ -93,10 +95,11 @@ export function _INTERNAL_captureSerializedLog(client: Client, serializedLog: Se
9395
* @experimental This method will experience breaking changes. This is not yet part of
9496
* the stable Sentry SDK API and can be changed or removed without warning.
9597
*/
98+
// eslint-disable-next-line complexity
9699
export function _INTERNAL_captureLog(
97100
beforeLog: Log,
98101
client: Client | undefined = getClient(),
99-
scope = getCurrentScope(),
102+
currentScope = getCurrentScope(),
100103
captureSerializedLog: (client: Client, log: SerializedLog) => void = _INTERNAL_captureSerializedLog,
101104
): void {
102105
if (!client) {
@@ -111,12 +114,27 @@ export function _INTERNAL_captureLog(
111114
return;
112115
}
113116

114-
const [, traceContext] = _getTraceInfoFromScope(client, scope);
117+
const [, traceContext] = _getTraceInfoFromScope(client, currentScope);
115118

116119
const processedLogAttributes = {
117120
...beforeLog.attributes,
118121
};
119122

123+
const { user } = getScopeData(currentScope);
124+
// Only attach user to log attributes if sendDefaultPii is enabled
125+
if (client.getOptions().sendDefaultPii) {
126+
const { id, email, username } = user;
127+
if (id && !processedLogAttributes['user.id']) {
128+
processedLogAttributes['user.id'] = id;
129+
}
130+
if (email && !processedLogAttributes['user.email']) {
131+
processedLogAttributes['user.email'] = email;
132+
}
133+
if (username && !processedLogAttributes['user.name']) {
134+
processedLogAttributes['user.name'] = username;
135+
}
136+
}
137+
120138
if (release) {
121139
processedLogAttributes['sentry.release'] = release;
122140
}
@@ -140,7 +158,7 @@ export function _INTERNAL_captureLog(
140158
});
141159
}
142160

143-
const span = _getSpanForScope(scope);
161+
const span = _getSpanForScope(currentScope);
144162
if (span) {
145163
// Add the parent span ID to the log attributes for trace context
146164
processedLogAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
@@ -218,3 +236,18 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array
218236
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedLog> | undefined {
219237
return GLOBAL_OBJ._sentryClientToLogBufferMap?.get(client);
220238
}
239+
240+
/**
241+
* Get the scope data for the current scope.
242+
* @param currentScope - The current scope.
243+
* @returns The scope data.
244+
*/
245+
function getScopeData(currentScope: Scope): ScopeData {
246+
const scopeData = getGlobalScope().getScopeData();
247+
const isolationScope = getIsolationScope();
248+
if (isolationScope) {
249+
mergeScopeData(scopeData, isolationScope.getScopeData());
250+
}
251+
mergeScopeData(scopeData, currentScope.getScopeData());
252+
return scopeData;
253+
}

packages/core/test/lib/logs/exports.test.ts

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,302 @@ describe('_INTERNAL_captureLog', () => {
375375
expect(beforeCaptureLogSpy).toHaveBeenCalledWith('afterCaptureLog', log);
376376
beforeCaptureLogSpy.mockRestore();
377377
});
378+
379+
describe('user functionality', () => {
380+
it('includes user data in log attributes when sendDefaultPii is enabled', () => {
381+
const options = getDefaultTestClientOptions({
382+
dsn: PUBLIC_DSN,
383+
_experiments: { enableLogs: true },
384+
sendDefaultPii: true,
385+
});
386+
const client = new TestClient(options);
387+
const scope = new Scope();
388+
scope.setUser({
389+
id: '123',
390+
391+
username: 'testuser',
392+
});
393+
394+
_INTERNAL_captureLog({ level: 'info', message: 'test log with user' }, client, scope);
395+
396+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
397+
expect(logAttributes).toEqual({
398+
'user.id': {
399+
value: '123',
400+
type: 'string',
401+
},
402+
'user.email': {
403+
404+
type: 'string',
405+
},
406+
'user.name': {
407+
value: 'testuser',
408+
type: 'string',
409+
},
410+
});
411+
});
412+
413+
it('does not include user data in log attributes when sendDefaultPii is disabled', () => {
414+
const options = getDefaultTestClientOptions({
415+
dsn: PUBLIC_DSN,
416+
_experiments: { enableLogs: true },
417+
sendDefaultPii: false,
418+
});
419+
const client = new TestClient(options);
420+
const scope = new Scope();
421+
scope.setUser({
422+
id: '123',
423+
424+
username: 'testuser',
425+
});
426+
427+
_INTERNAL_captureLog({ level: 'info', message: 'test log without user' }, client, scope);
428+
429+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
430+
expect(logAttributes).toEqual({});
431+
});
432+
433+
it('includes partial user data when only some fields are available', () => {
434+
const options = getDefaultTestClientOptions({
435+
dsn: PUBLIC_DSN,
436+
_experiments: { enableLogs: true },
437+
sendDefaultPii: true,
438+
});
439+
const client = new TestClient(options);
440+
const scope = new Scope();
441+
scope.setUser({
442+
id: '123',
443+
// email and username are missing
444+
});
445+
446+
_INTERNAL_captureLog({ level: 'info', message: 'test log with partial user' }, client, scope);
447+
448+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
449+
expect(logAttributes).toEqual({
450+
'user.id': {
451+
value: '123',
452+
type: 'string',
453+
},
454+
});
455+
});
456+
457+
it('includes user email and username without id', () => {
458+
const options = getDefaultTestClientOptions({
459+
dsn: PUBLIC_DSN,
460+
_experiments: { enableLogs: true },
461+
sendDefaultPii: true,
462+
});
463+
const client = new TestClient(options);
464+
const scope = new Scope();
465+
scope.setUser({
466+
467+
username: 'testuser',
468+
// id is missing
469+
});
470+
471+
_INTERNAL_captureLog({ level: 'info', message: 'test log with email and username' }, client, scope);
472+
473+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
474+
expect(logAttributes).toEqual({
475+
'user.email': {
476+
477+
type: 'string',
478+
},
479+
'user.name': {
480+
value: 'testuser',
481+
type: 'string',
482+
},
483+
});
484+
});
485+
486+
it('does not include user data when user object is empty', () => {
487+
const options = getDefaultTestClientOptions({
488+
dsn: PUBLIC_DSN,
489+
_experiments: { enableLogs: true },
490+
sendDefaultPii: true,
491+
});
492+
const client = new TestClient(options);
493+
const scope = new Scope();
494+
scope.setUser({});
495+
496+
_INTERNAL_captureLog({ level: 'info', message: 'test log with empty user' }, client, scope);
497+
498+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
499+
expect(logAttributes).toEqual({});
500+
});
501+
502+
it('combines user data with other log attributes', () => {
503+
const options = getDefaultTestClientOptions({
504+
dsn: PUBLIC_DSN,
505+
_experiments: { enableLogs: true },
506+
sendDefaultPii: true,
507+
release: '1.0.0',
508+
environment: 'test',
509+
});
510+
const client = new TestClient(options);
511+
const scope = new Scope();
512+
scope.setUser({
513+
id: '123',
514+
515+
});
516+
517+
_INTERNAL_captureLog(
518+
{
519+
level: 'info',
520+
message: 'test log with user and other attributes',
521+
attributes: { component: 'auth', action: 'login' },
522+
},
523+
client,
524+
scope,
525+
);
526+
527+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
528+
expect(logAttributes).toEqual({
529+
component: {
530+
value: 'auth',
531+
type: 'string',
532+
},
533+
action: {
534+
value: 'login',
535+
type: 'string',
536+
},
537+
'user.id': {
538+
value: '123',
539+
type: 'string',
540+
},
541+
'user.email': {
542+
543+
type: 'string',
544+
},
545+
'sentry.release': {
546+
value: '1.0.0',
547+
type: 'string',
548+
},
549+
'sentry.environment': {
550+
value: 'test',
551+
type: 'string',
552+
},
553+
});
554+
});
555+
556+
it('handles user data with non-string values', () => {
557+
const options = getDefaultTestClientOptions({
558+
dsn: PUBLIC_DSN,
559+
_experiments: { enableLogs: true },
560+
sendDefaultPii: true,
561+
});
562+
const client = new TestClient(options);
563+
const scope = new Scope();
564+
scope.setUser({
565+
id: 123, // number instead of string
566+
567+
username: undefined, // undefined value
568+
});
569+
570+
_INTERNAL_captureLog({ level: 'info', message: 'test log with non-string user values' }, client, scope);
571+
572+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
573+
expect(logAttributes).toEqual({
574+
'user.id': {
575+
value: 123,
576+
type: 'integer',
577+
},
578+
'user.email': {
579+
580+
type: 'string',
581+
},
582+
});
583+
});
584+
585+
it('preserves existing user attributes in log and does not override them', () => {
586+
const options = getDefaultTestClientOptions({
587+
dsn: PUBLIC_DSN,
588+
_experiments: { enableLogs: true },
589+
sendDefaultPii: true,
590+
});
591+
const client = new TestClient(options);
592+
const scope = new Scope();
593+
scope.setUser({
594+
id: '123',
595+
596+
});
597+
598+
_INTERNAL_captureLog(
599+
{
600+
level: 'info',
601+
message: 'test log with existing user attributes',
602+
attributes: {
603+
'user.id': 'existing-id', // This should NOT be overridden by scope user
604+
'user.custom': 'custom-value', // This should be preserved
605+
},
606+
},
607+
client,
608+
scope,
609+
);
610+
611+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
612+
expect(logAttributes).toEqual({
613+
'user.custom': {
614+
value: 'custom-value',
615+
type: 'string',
616+
},
617+
'user.id': {
618+
value: 'existing-id', // Existing value is preserved
619+
type: 'string',
620+
},
621+
'user.email': {
622+
value: '[email protected]', // Only added because user.email wasn't already present
623+
type: 'string',
624+
},
625+
});
626+
});
627+
628+
it('only adds scope user data for attributes that do not already exist', () => {
629+
const options = getDefaultTestClientOptions({
630+
dsn: PUBLIC_DSN,
631+
_experiments: { enableLogs: true },
632+
sendDefaultPii: true,
633+
});
634+
const client = new TestClient(options);
635+
const scope = new Scope();
636+
scope.setUser({
637+
id: 'scope-id',
638+
639+
username: 'scope-user',
640+
});
641+
642+
_INTERNAL_captureLog(
643+
{
644+
level: 'info',
645+
message: 'test log with partial existing user attributes',
646+
attributes: {
647+
'user.email': '[email protected]', // This should be preserved
648+
'other.attr': 'value',
649+
},
650+
},
651+
client,
652+
scope,
653+
);
654+
655+
const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes;
656+
expect(logAttributes).toEqual({
657+
'other.attr': {
658+
value: 'value',
659+
type: 'string',
660+
},
661+
'user.email': {
662+
value: '[email protected]', // Existing email is preserved
663+
type: 'string',
664+
},
665+
'user.id': {
666+
value: 'scope-id', // Added from scope because not present
667+
type: 'string',
668+
},
669+
'user.name': {
670+
value: 'scope-user', // Added from scope because not present
671+
type: 'string',
672+
},
673+
});
674+
});
675+
});
378676
});

0 commit comments

Comments
 (0)