Skip to content

Commit 263e16c

Browse files
committed
feat(consola): Enhance Consola integration to extract objects as searchable attributes
1 parent 65f7b87 commit 263e16c

4 files changed

Lines changed: 293 additions & 15 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
import { consola } from 'consola';
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
release: '1.0.0',
8+
environment: 'test',
9+
enableLogs: true,
10+
transport: loggingTransport,
11+
});
12+
13+
async function run(): Promise<void> {
14+
consola.level = 5;
15+
16+
const sentryReporter = Sentry.createConsolaReporter();
17+
consola.addReporter(sentryReporter);
18+
19+
// Object context extraction - objects should become searchable attributes
20+
consola.info('User logged in', { userId: 123, sessionId: 'abc-123' });
21+
22+
// Multiple objects - properties should be merged
23+
consola.warn('Payment processed', { orderId: 456 }, { amount: 99.99, currency: 'USD' });
24+
25+
// Mixed primitives and objects
26+
consola.error('Error occurred', 'in payment module', { errorCode: 'E001', retryable: true });
27+
28+
// Aarrays (should be stored as context attributes)
29+
consola.debug('Processing items', [1, 2, 3, 4, 5]);
30+
31+
// Nested objects
32+
consola.info('Complex data', { user: { id: 789, name: 'Jane' }, metadata: { source: 'api' } });
33+
34+
await Sentry.flush();
35+
}
36+
37+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
38+
void run();

dev-packages/node-integration-tests/suites/consola/test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ describe('consola integration', () => {
279279
{
280280
timestamp: expect.any(Number),
281281
level: 'info',
282-
body: 'Message with args: hello 123 {"key":"value"} [1,2,3]',
282+
body: 'Message with args: hello 123',
283283
severity_number: expect.any(Number),
284284
trace_id: expect.any(String),
285285
attributes: {
@@ -291,6 +291,7 @@ describe('consola integration', () => {
291291
'server.address': { value: expect.any(String), type: 'string' },
292292
'consola.type': { value: 'info', type: 'string' },
293293
'consola.level': { value: 3, type: 'integer' },
294+
key: { value: 'value', type: 'string' },
294295
},
295296
},
296297
{
@@ -491,4 +492,112 @@ describe('consola integration', () => {
491492

492493
await runner.completed();
493494
});
495+
496+
test('should extract objects as searchable context attributes', async () => {
497+
const runner = createRunner(__dirname, 'subject-object-context.ts')
498+
.expect({
499+
log: {
500+
items: [
501+
{
502+
timestamp: expect.any(Number),
503+
level: 'info',
504+
body: 'User logged in',
505+
severity_number: expect.any(Number),
506+
trace_id: expect.any(String),
507+
attributes: {
508+
'sentry.origin': { value: 'auto.log.consola', type: 'string' },
509+
'sentry.release': { value: '1.0.0', type: 'string' },
510+
'sentry.environment': { value: 'test', type: 'string' },
511+
'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
512+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
513+
'server.address': { value: expect.any(String), type: 'string' },
514+
'consola.type': { value: 'info', type: 'string' },
515+
'consola.level': { value: 3, type: 'integer' },
516+
userId: { value: 123, type: 'integer' },
517+
sessionId: { value: 'abc-123', type: 'string' },
518+
},
519+
},
520+
{
521+
timestamp: expect.any(Number),
522+
level: 'warn',
523+
body: 'Payment processed',
524+
severity_number: expect.any(Number),
525+
trace_id: expect.any(String),
526+
attributes: {
527+
'sentry.origin': { value: 'auto.log.consola', type: 'string' },
528+
'sentry.release': { value: '1.0.0', type: 'string' },
529+
'sentry.environment': { value: 'test', type: 'string' },
530+
'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
531+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
532+
'server.address': { value: expect.any(String), type: 'string' },
533+
'consola.type': { value: 'warn', type: 'string' },
534+
'consola.level': { value: 1, type: 'integer' },
535+
orderId: { value: 456, type: 'integer' },
536+
amount: { value: 99.99, type: 'double' },
537+
currency: { value: 'USD', type: 'string' },
538+
},
539+
},
540+
{
541+
timestamp: expect.any(Number),
542+
level: 'error',
543+
body: 'Error occurred in payment module',
544+
severity_number: expect.any(Number),
545+
trace_id: expect.any(String),
546+
attributes: {
547+
'sentry.origin': { value: 'auto.log.consola', type: 'string' },
548+
'sentry.release': { value: '1.0.0', type: 'string' },
549+
'sentry.environment': { value: 'test', type: 'string' },
550+
'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
551+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
552+
'server.address': { value: expect.any(String), type: 'string' },
553+
'consola.type': { value: 'error', type: 'string' },
554+
'consola.level': { value: 0, type: 'integer' },
555+
errorCode: { value: 'E001', type: 'string' },
556+
retryable: { value: true, type: 'boolean' },
557+
},
558+
},
559+
{
560+
timestamp: expect.any(Number),
561+
level: 'debug',
562+
body: 'Processing items',
563+
severity_number: expect.any(Number),
564+
trace_id: expect.any(String),
565+
attributes: {
566+
'sentry.origin': { value: 'auto.log.consola', type: 'string' },
567+
'sentry.release': { value: '1.0.0', type: 'string' },
568+
'sentry.environment': { value: 'test', type: 'string' },
569+
'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
570+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
571+
'server.address': { value: expect.any(String), type: 'string' },
572+
'consola.type': { value: 'debug', type: 'string' },
573+
'consola.level': { value: 4, type: 'integer' },
574+
'consola.context.0': { value: '[1,2,3,4,5]', type: 'string' },
575+
},
576+
},
577+
{
578+
timestamp: expect.any(Number),
579+
level: 'info',
580+
body: 'Complex data',
581+
severity_number: expect.any(Number),
582+
trace_id: expect.any(String),
583+
attributes: {
584+
'sentry.origin': { value: 'auto.log.consola', type: 'string' },
585+
'sentry.release': { value: '1.0.0', type: 'string' },
586+
'sentry.environment': { value: 'test', type: 'string' },
587+
'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
588+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
589+
'server.address': { value: expect.any(String), type: 'string' },
590+
'consola.type': { value: 'info', type: 'string' },
591+
'consola.level': { value: 3, type: 'integer' },
592+
user: { value: '{"id":789,"name":"Jane"}', type: 'string' },
593+
metadata: { value: '{"source":"api"}', type: 'string' },
594+
},
595+
},
596+
],
597+
},
598+
})
599+
.start();
600+
601+
await runner.completed();
602+
});
494603
});

packages/core/src/integrations/consola.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getClient } from '../currentScopes';
33
import { _INTERNAL_captureLog } from '../logs/internal';
44
import { formatConsoleArgs } from '../logs/utils';
55
import type { LogSeverityLevel } from '../types-hoist/log';
6+
import { isPrimitive } from '../utils/is';
67

78
/**
89
* Options for the Sentry Consola reporter.
@@ -206,17 +207,7 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con
206207

207208
const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = client.getOptions();
208209

209-
// Format the log message using the same approach as consola's basic reporter
210-
const messageParts = [];
211-
if (consolaMessage) {
212-
messageParts.push(consolaMessage);
213-
}
214-
if (args && args.length > 0) {
215-
messageParts.push(formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth));
216-
}
217-
const message = messageParts.join(' ');
218-
219-
// Build attributes
210+
// Build base attributes first
220211
attributes['sentry.origin'] = 'auto.log.consola';
221212

222213
if (tag) {
@@ -232,6 +223,44 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con
232223
attributes['consola.level'] = level;
233224
}
234225

226+
// Process args: separate primitives for message, extract objects as attributes
227+
let message = consolaMessage || '';
228+
if (args?.length) {
229+
const primitives: unknown[] = [];
230+
let contextIndex = 0;
231+
232+
for (const arg of args) {
233+
if (isPrimitive(arg)) {
234+
primitives.push(arg);
235+
} else if (typeof arg === 'object' && arg !== null) {
236+
// Plain objects: extract properties as attributes
237+
if (!Array.isArray(arg)) {
238+
try {
239+
for (const key in arg) {
240+
// Only add if not conflicting with existing or consola-prefixed attributes
241+
if (!(key in attributes) && !(`consola.${key}` in attributes)) {
242+
attributes[key] = (arg as Record<string, unknown>)[key];
243+
}
244+
}
245+
} catch {
246+
// Skip on error
247+
}
248+
} else {
249+
// Arrays: store as context attribute as they don't have meaningful property names, just numeric indices
250+
attributes[`consola.context.${contextIndex++}`] = arg;
251+
}
252+
} else {
253+
primitives.push(arg);
254+
}
255+
}
256+
257+
if (primitives.length) {
258+
message = message
259+
? `${message} ${formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth)}`
260+
: formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth);
261+
}
262+
}
263+
235264
_INTERNAL_captureLog({
236265
level: logSeverityLevel,
237266
message,

packages/core/test/lib/integrations/consola.test.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,13 +184,14 @@ describe('createConsolaReporter', () => {
184184

185185
sentryReporter.log(logObj);
186186

187-
expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000);
187+
expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123], 3, 1000);
188188
expect(_INTERNAL_captureLog).toHaveBeenCalledWith({
189189
level: 'info',
190-
message: 'Hello world 123 {"key":"value"}',
190+
message: 'Hello world 123',
191191
attributes: {
192192
'sentry.origin': 'auto.log.consola',
193193
'consola.type': 'info',
194+
key: 'value',
194195
},
195196
});
196197
});
@@ -208,14 +209,115 @@ describe('createConsolaReporter', () => {
208209

209210
expect(_INTERNAL_captureLog).toHaveBeenCalledWith({
210211
level: 'info',
211-
message: 'Message {"self":"[Circular ~]"}',
212+
message: 'Message',
212213
attributes: {
213214
'sentry.origin': 'auto.log.consola',
214215
'consola.type': 'info',
216+
self: circular,
215217
},
216218
});
217219
});
218220

221+
it('should extract multiple objects as attributes', () => {
222+
const logObj = {
223+
type: 'info',
224+
message: 'User action',
225+
args: [{ userId: 123 }, { sessionId: 'abc-123' }],
226+
};
227+
228+
sentryReporter.log(logObj);
229+
230+
expect(_INTERNAL_captureLog).toHaveBeenCalledWith({
231+
level: 'info',
232+
message: 'User action',
233+
attributes: {
234+
'sentry.origin': 'auto.log.consola',
235+
'consola.type': 'info',
236+
userId: 123,
237+
sessionId: 'abc-123',
238+
},
239+
});
240+
});
241+
242+
it('should handle mixed primitives and objects in args', () => {
243+
const logObj = {
244+
type: 'info',
245+
args: ['Processing', { userId: 456 }, 'for', { action: 'login' }],
246+
};
247+
248+
sentryReporter.log(logObj);
249+
250+
expect(formatConsoleArgs).toHaveBeenCalledWith(['Processing', 'for'], 3, 1000);
251+
expect(_INTERNAL_captureLog).toHaveBeenCalledWith({
252+
level: 'info',
253+
message: 'Processing for',
254+
attributes: {
255+
'sentry.origin': 'auto.log.consola',
256+
'consola.type': 'info',
257+
userId: 456,
258+
action: 'login',
259+
},
260+
});
261+
});
262+
263+
it('should handle arrays as context attributes', () => {
264+
const logObj = {
265+
type: 'info',
266+
message: 'Array data',
267+
args: [[1, 2, 3]],
268+
};
269+
270+
sentryReporter.log(logObj);
271+
272+
expect(_INTERNAL_captureLog).toHaveBeenCalledWith({
273+
level: 'info',
274+
message: 'Array data',
275+
attributes: {
276+
'sentry.origin': 'auto.log.consola',
277+
'consola.type': 'info',
278+
'consola.context.0': [1, 2, 3],
279+
},
280+
});
281+
});
282+
283+
it('should not override existing attributes with object properties', () => {
284+
const logObj = {
285+
type: 'info',
286+
message: 'Test',
287+
tag: 'api',
288+
args: [{ tag: 'should-not-override' }],
289+
};
290+
291+
sentryReporter.log(logObj);
292+
293+
expect(_INTERNAL_captureLog).toHaveBeenCalledWith({
294+
level: 'info',
295+
message: 'Test',
296+
attributes: {
297+
'sentry.origin': 'auto.log.consola',
298+
'consola.type': 'info',
299+
'consola.tag': 'api',
300+
// tag should not be overridden by the object arg
301+
},
302+
});
303+
});
304+
305+
it('should handle objects with nested properties', () => {
306+
const logObj = {
307+
type: 'info',
308+
args: ['Event', { user: { id: 123, name: 'John' }, timestamp: Date.now() }],
309+
};
310+
311+
sentryReporter.log(logObj);
312+
313+
const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0];
314+
expect(captureCall.level).toBe('info');
315+
expect(captureCall.message).toBe('Event');
316+
expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola');
317+
expect(captureCall.attributes.user).toEqual({ id: 123, name: 'John' });
318+
expect(captureCall.attributes.timestamp).toEqual(expect.any(Number));
319+
});
320+
219321
it('should map consola levels to sentry levels when type is not provided', () => {
220322
const logObj = {
221323
level: 0, // Fatal level

0 commit comments

Comments
 (0)