11import type { Client } from '../client' ;
22import { getClient } from '../currentScopes' ;
33import { _INTERNAL_captureLog } from '../logs/internal' ;
4- import { formatConsoleArgs } from '../logs/utils' ;
4+ import { createConsoleTemplateAttributes , formatConsoleArgs , hasConsoleSubstitutions } from '../logs/utils' ;
55import type { LogSeverityLevel } from '../types-hoist/log' ;
6- import { isPlainObject , isPrimitive } from '../utils/is' ;
6+ import { isPlainObject } from '../utils/is' ;
77import { 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
4492export interface ConsolaReporter {
@@ -64,6 +112,7 @@ export interface ConsolaReporter {
64112export 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
154206const 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