Skip to content

Commit 35c34e1

Browse files
committed
generally works
1 parent dfb0a81 commit 35c34e1

3 files changed

Lines changed: 839 additions & 117 deletions

File tree

dev-packages/node-integration-tests/suites/consola/subject-object-context.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,19 @@ async function run(): Promise<void> {
1616
const sentryReporter = Sentry.createConsolaReporter();
1717
consola.addReporter(sentryReporter);
1818

19-
// Object context extraction - objects should become searchable attributes
19+
// --- Fallback (first arg is string): message = formatted(all args), sentry.message.template + sentry.message.parameter.* ---
20+
// Expected: message = "User logged in {...}", sentry.message.template = "User logged in {}", sentry.message.parameter.0 = { userId, sessionId }
2021
consola.info('User logged in', { userId: 123, sessionId: 'abc-123' });
2122

22-
// Multiple objects - properties should be merged
23+
// Expected: message = formatted string, template + params for each following arg
2324
consola.warn('Payment processed', { orderId: 456 }, { amount: 99.99, currency: 'USD' });
2425

25-
// Mixed primitives and objects
2626
consola.error('Error occurred', 'in payment module', { errorCode: 'E001', retryable: true });
2727

28-
// Arrays (should be stored as context attributes)
2928
consola.debug('Processing items', [1, 2, 3, 4, 5]);
3029

31-
// Nested objects
3230
consola.info('Complex data', { user: { id: 789, name: 'Jane' }, metadata: { source: 'api' } });
3331

34-
// Deep nesting to test normalizeDepth (should be normalized at depth 3 - default)
3532
consola.info('Deep object', {
3633
level1: {
3734
level2: {
@@ -43,6 +40,26 @@ async function run(): Promise<void> {
4340
simpleKey: 'simple value',
4441
});
4542

43+
// --- Object-first (first arg is plain object): attributes from object, message = second arg if string, rest → sentry.message.parameter.* ---
44+
// Expected: message = "User action", attributes userId: 789, sentry.message.parameter.0 = requestId, sentry.message.parameter.1 = timestamp
45+
consola.info({ userId: 789 }, 'User action', 'req-123', 1234567890);
46+
47+
// Expected: message = "", attributes from object only
48+
consola.log({ event: 'click', buttonId: 'submit' });
49+
50+
// --- Consola-merged (consola.log({ message, ...rest })): Consola puts message in args[0] and spreads rest on logObj ---
51+
// Expected: message = "inline-message", attributes userId, action, time (from logObj)
52+
consola.log({
53+
message: 'inline-message',
54+
userId: 123,
55+
action: 'login',
56+
time: new Date(),
57+
});
58+
59+
// Fallback "Legacy log" style: first arg string, rest as params
60+
// Expected: message = "Legacy log {...} 123", sentry.message.template = "Legacy log {} {}", sentry.message.parameter.0 = { data: 1 }, .1 = 123
61+
consola.log('Legacy log', { data: 1 }, 123);
62+
4663
await Sentry.flush();
4764
}
4865

packages/core/src/integrations/consola.ts

Lines changed: 227 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
11
import type { Client } from '../client';
22
import { getClient } from '../currentScopes';
33
import { _INTERNAL_captureLog } from '../logs/internal';
4-
import { formatConsoleArgs } from '../logs/utils';
4+
import { createConsoleTemplateAttributes, formatConsoleArgs, hasConsoleSubstitutions } from '../logs/utils';
55
import type { LogSeverityLevel } from '../types-hoist/log';
6-
import { isPlainObject, isPrimitive } from '../utils/is';
6+
import { isPlainObject } from '../utils/is';
77
import { normalize } from '../utils/normalize';
88

9+
/**
10+
* Result of extracting structured attributes from console arguments.
11+
*/
12+
export interface ExtractAttributesResult {
13+
/**
14+
* Extracted attributes to add to the log.
15+
*/
16+
attributes?: Record<string, unknown>;
17+
18+
/**
19+
* The message to use (if determined).
20+
*/
21+
message?: string;
22+
23+
/**
24+
* Remaining arguments to process as parameters.
25+
*/
26+
remainingArgs?: unknown[];
27+
}
28+
929
/**
1030
* Options for the Sentry Consola reporter.
1131
*/
@@ -39,6 +59,34 @@ interface ConsolaReporterOptions {
3959
* ```
4060
*/
4161
client?: Client;
62+
63+
/**
64+
* Custom function to extract structured attributes from console arguments.
65+
*
66+
* Return null/undefined to use default behavior, which extracts attributes
67+
* when the first argument is a plain object.
68+
*
69+
* @param args - The raw arguments from consola
70+
* @returns Extraction result or null for default behavior
71+
*
72+
* @example
73+
* ```ts
74+
* const sentryReporter = Sentry.createConsolaReporter({
75+
* extractAttributes: (args) => {
76+
* // Custom logic to determine attributes
77+
* if (args[0]?.type === 'structured') {
78+
* return {
79+
* attributes: args[0],
80+
* message: args[1],
81+
* remainingArgs: args.slice(2)
82+
* };
83+
* }
84+
* return null; // Use default behavior
85+
* }
86+
* });
87+
* ```
88+
*/
89+
extractAttributes?: (args: unknown[]) => ExtractAttributesResult | null | undefined;
4290
}
4391

4492
export interface ConsolaReporter {
@@ -64,6 +112,7 @@ export interface ConsolaReporter {
64112
export interface ConsolaLogObject {
65113
/**
66114
* Allows additional custom properties to be set on the log object.
115+
* todo: they are not prefixed?
67116
* These properties will be captured as log attributes with a 'consola.' prefix.
68117
*
69118
* @example
@@ -75,6 +124,7 @@ export interface ConsolaLogObject {
75124
* userId: 123,
76125
* sessionId: 'abc-123'
77126
* });
127+
* todo: NOOO it does not?
78128
* // Will create attributes: consola.userId and consola.sessionId
79129
* ```
80130
*/
@@ -147,12 +197,139 @@ export interface ConsolaLogObject {
147197
*
148198
* When provided, this is the final formatted message. When not provided,
149199
* the message should be constructed from the `args` array.
200+
*
201+
* In reporters, this is probably always undefined: https://github.com/unjs/consola/issues/406#issuecomment-3684792551
150202
*/
151203
message?: string;
152204
}
153205

154206
const DEFAULT_CAPTURED_LEVELS: Array<LogSeverityLevel> = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
155207

208+
/**
209+
* Extracts structured attributes from args if first arg is a plain object.
210+
*
211+
* @param args - The console arguments
212+
* @param normalizeDepth - The depth to normalize the values
213+
* @param normalizeMaxBreadth - The max breadth to normalize the values
214+
* @returns Extraction result with attributes, message, and remaining args, or null for fallback behavior
215+
*/
216+
function extractStructuredAttributes(
217+
args: unknown[] | undefined,
218+
normalizeDepth: number,
219+
normalizeMaxBreadth: number,
220+
): ExtractAttributesResult | null {
221+
if (!args || args.length === 0) {
222+
return null;
223+
}
224+
225+
const firstArg = args[0];
226+
227+
// Check if first arg is a plain object
228+
if (!isPlainObject(firstArg)) {
229+
return null; // Fallback to legacy behavior
230+
}
231+
232+
// Extract attributes from first arg
233+
const attributes = normalize(firstArg, normalizeDepth, normalizeMaxBreadth) as Record<string, unknown>;
234+
235+
// Determine message (second arg if string, otherwise empty)
236+
const secondArg = args[1];
237+
const message = typeof secondArg === 'string' ? secondArg : '';
238+
239+
// Remaining args start from index 2 if we used second arg as message, otherwise from index 1
240+
const remainingArgsStartIndex = typeof secondArg === 'string' ? 2 : 1;
241+
const remainingArgs = args.slice(remainingArgsStartIndex);
242+
243+
return {
244+
attributes,
245+
message,
246+
remainingArgs,
247+
};
248+
}
249+
250+
/**
251+
* Processes args in fallback mode (same as console integration): formatted message plus template and parameters.
252+
* Does not extract objects from args as attributes.
253+
*
254+
* @param args - The console arguments
255+
* @param consolaMessage - The message from consola (used when args is empty)
256+
* @param normalizeDepth - The depth to normalize the values
257+
* @param normalizeMaxBreadth - The max breadth to normalize the values
258+
* @returns Object containing the message and message attributes (template + parameters)
259+
*/
260+
function processArgsFallbackMode(
261+
args: unknown[] | undefined,
262+
consolaMessage: string | undefined,
263+
normalizeDepth: number,
264+
normalizeMaxBreadth: number,
265+
): { message: string; messageAttributes: Record<string, unknown> } {
266+
const messageAttributes: Record<string, unknown> = {};
267+
268+
if (!args?.length) {
269+
return { message: consolaMessage || '', messageAttributes };
270+
}
271+
272+
const message = formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth);
273+
274+
const firstArg = args[0];
275+
const followingArgs = args.slice(1);
276+
277+
if (followingArgs.length > 0 && typeof firstArg === 'string' && !hasConsoleSubstitutions(firstArg)) {
278+
const templateAttrs = createConsoleTemplateAttributes(firstArg, followingArgs);
279+
for (const key in templateAttrs) {
280+
const value = templateAttrs[key];
281+
messageAttributes[key] = key.startsWith('sentry.message.parameter.')
282+
? normalize(value, normalizeDepth, normalizeMaxBreadth)
283+
: value;
284+
}
285+
}
286+
287+
return { message, messageAttributes };
288+
}
289+
290+
/**
291+
* Processes structured extraction result and builds message and attributes.
292+
*
293+
* @param extractionResult - The result from extraction
294+
* @param consolaMessage - The message from consola
295+
* @param attributes - The attributes object to add extracted properties to
296+
* @param normalizeDepth - The depth to normalize the values
297+
* @param normalizeMaxBreadth - The max breadth to normalize the values
298+
* @returns Object containing the message and message attributes
299+
*/
300+
function processStructuredMode(
301+
extractionResult: ExtractAttributesResult,
302+
consolaMessage: string | undefined,
303+
attributes: Record<string, unknown>,
304+
normalizeDepth: number,
305+
normalizeMaxBreadth: number,
306+
): { message: string; messageAttributes: Record<string, unknown> } {
307+
const { attributes: extractedAttrs, message: extractedMsg, remainingArgs } = extractionResult;
308+
const messageAttributes: Record<string, unknown> = {};
309+
310+
// Use extracted message or consolaMessage
311+
const message = extractedMsg || consolaMessage || '';
312+
313+
// Add extracted attributes, but don't override existing or consola-prefixed attributes
314+
if (extractedAttrs) {
315+
for (const key in extractedAttrs) {
316+
// Only add if not conflicting with existing or consola-prefixed attributes
317+
if (!(key in attributes) && !(`consola.${key}` in attributes)) {
318+
attributes[key] = extractedAttrs[key];
319+
}
320+
}
321+
}
322+
323+
// Add remaining args as parameters
324+
if (remainingArgs && remainingArgs.length > 0) {
325+
remainingArgs.forEach((arg, index) => {
326+
messageAttributes[`sentry.message.parameter.${index}`] = normalize(arg, normalizeDepth, normalizeMaxBreadth);
327+
});
328+
}
329+
330+
return { message, messageAttributes };
331+
}
332+
156333
/**
157334
* Creates a new Sentry reporter for Consola that forwards logs to Sentry. Requires the `enableLogs` option to be enabled.
158335
*
@@ -189,8 +366,13 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con
189366

190367
return {
191368
log(logObj: ConsolaLogObject) {
192-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
193-
const { type, level, message: consolaMessage, args, tag, date: _date, ...attributes } = logObj;
369+
const { type, level, message: consolaMessage, args, tag, date: _date, ...rest } = logObj;
370+
371+
// Extra keys on logObj (beyond reserved) indicate consola merged a single object, e.g. consola.log({ message: "x", userId: 1 })
372+
const hasExtraKeys = Object.keys(rest).length > 0;
373+
374+
// Build attributes: extra keys first, then add reserved base attributes
375+
const attributes: Record<string, unknown> = { ...rest };
194376

195377
// Get client - use provided client or current client
196378
const client = providedClient || getClient();
@@ -208,7 +390,6 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con
208390

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

211-
// Build base attributes first
212393
attributes['sentry.origin'] = 'auto.log.consola';
213394

214395
if (tag) {
@@ -224,47 +405,49 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con
224405
attributes['consola.level'] = level;
225406
}
226407

227-
// Process args: separate primitives for message, extract objects as attributes
228-
let message = consolaMessage || '';
229-
if (args?.length) {
230-
const primitives: unknown[] = [];
231-
let contextIndex = 0;
232-
233-
for (const arg of args) {
234-
if (isPrimitive(arg)) {
235-
primitives.push(arg);
236-
} else if (typeof arg === 'object' && arg !== null) {
237-
// Plain objects: extract properties as individual attributes
238-
if (isPlainObject(arg)) {
239-
try {
240-
for (const key in arg) {
241-
// Only add if not conflicting with existing or consola-prefixed attributes
242-
if (!(key in attributes) && !(`consola.${key}` in attributes)) {
243-
// Normalize the value to respect normalizeDepth
244-
attributes[key] = normalize(arg[key], normalizeDepth, normalizeMaxBreadth);
245-
}
246-
}
247-
} catch {
248-
// Skip on error
249-
}
250-
} else {
251-
// Non-plain objects (Date, Error, Map, Set, etc.) and arrays: Store as args attribute so they get properly serialized
252-
// Special handling for Map and Set to preserve their data
253-
attributes[`consola.args.${contextIndex++}`] =
254-
arg instanceof Map ? Object.fromEntries(arg) : arg instanceof Set ? Array.from(arg) : arg;
255-
}
256-
} else {
257-
primitives.push(arg);
258-
}
259-
}
260-
261-
if (primitives.length) {
262-
message = message
263-
? `${message} ${formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth)}`
264-
: formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth);
265-
}
408+
// Consola-merged: single object was spread by consola (e.g. consola.log({ message: "inline-message", userId, action }))
409+
if (hasExtraKeys && args && args.length >= 1 && typeof args[0] === 'string') {
410+
const message = args[0];
411+
_INTERNAL_captureLog({
412+
level: logSeverityLevel,
413+
message,
414+
attributes,
415+
});
416+
return;
266417
}
267418

419+
// Try custom extraction first
420+
let extractionResult: ExtractAttributesResult | null = null;
421+
if (options.extractAttributes && args) {
422+
extractionResult = options.extractAttributes(args) || null;
423+
}
424+
425+
// Object-first: first arg is plain object
426+
if (!extractionResult && args && args.length > 0 && isPlainObject(args[0])) {
427+
extractionResult = extractStructuredAttributes(args, normalizeDepth, normalizeMaxBreadth);
428+
}
429+
430+
let message: string;
431+
let messageAttributes: Record<string, unknown> = {};
432+
433+
if (extractionResult) {
434+
const result = processStructuredMode(
435+
extractionResult,
436+
consolaMessage,
437+
attributes,
438+
normalizeDepth,
439+
normalizeMaxBreadth,
440+
);
441+
message = result.message;
442+
messageAttributes = result.messageAttributes;
443+
} else {
444+
const fallback = processArgsFallbackMode(args, consolaMessage, normalizeDepth, normalizeMaxBreadth);
445+
message = fallback.message;
446+
messageAttributes = fallback.messageAttributes;
447+
}
448+
449+
Object.assign(attributes, messageAttributes);
450+
268451
_INTERNAL_captureLog({
269452
level: logSeverityLevel,
270453
message,

0 commit comments

Comments
 (0)