@kabran-tecnologia/kabran-config 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * CI Result Trends Module
3
+ *
4
+ * Calculates trends from historical CI run data.
5
+ * Analyzes score, performance, and issues over time.
6
+ *
7
+ * @module ci-result-trends
8
+ */
9
+
10
+ import { filterByDays, filterByBranch } from './ci-result-history.mjs'
11
+
12
+ /**
13
+ * Direction indicators for trends
14
+ */
15
+ export const DIRECTION = {
16
+ IMPROVING: 'improving',
17
+ DEGRADING: 'degrading',
18
+ STABLE: 'stable',
19
+ }
20
+
21
+ /**
22
+ * Threshold for determining significant change (percentage)
23
+ */
24
+ export const SIGNIFICANCE_THRESHOLD = 5
25
+
26
+ /**
27
+ * Calculate average of numeric values
28
+ *
29
+ * @param {number[]} values - Array of numbers
30
+ * @returns {number} Average value
31
+ */
32
+ export function calculateAverage(values) {
33
+ if (!values || values.length === 0) return 0
34
+ const sum = values.reduce((acc, val) => acc + (val || 0), 0)
35
+ return sum / values.length
36
+ }
37
+
38
+ /**
39
+ * Calculate percentage change between two values
40
+ *
41
+ * @param {number} oldValue - Previous value
42
+ * @param {number} newValue - Current value
43
+ * @returns {number} Percentage change (null if cannot calculate)
44
+ */
45
+ export function calculatePercentageChange(oldValue, newValue) {
46
+ if (oldValue === 0) {
47
+ if (newValue === 0) return 0
48
+ return newValue > 0 ? 100 : -100
49
+ }
50
+ return ((newValue - oldValue) / Math.abs(oldValue)) * 100
51
+ }
52
+
53
+ /**
54
+ * Calculate absolute change between two values
55
+ *
56
+ * @param {number} oldValue - Previous value
57
+ * @param {number} newValue - Current value
58
+ * @returns {number} Absolute change
59
+ */
60
+ export function calculateAbsoluteChange(oldValue, newValue) {
61
+ return newValue - oldValue
62
+ }
63
+
64
+ /**
65
+ * Determine direction of trend
66
+ *
67
+ * @param {number} changePercent - Percentage change
68
+ * @param {number} threshold - Significance threshold
69
+ * @param {boolean} higherIsBetter - Whether higher values are better
70
+ * @returns {string} Direction: 'improving', 'degrading', or 'stable'
71
+ */
72
+ export function determineDirection(changePercent, threshold = SIGNIFICANCE_THRESHOLD, higherIsBetter = true) {
73
+ if (Math.abs(changePercent) < threshold) {
74
+ return DIRECTION.STABLE
75
+ }
76
+
77
+ const isPositive = changePercent > 0
78
+
79
+ if (higherIsBetter) {
80
+ return isPositive ? DIRECTION.IMPROVING : DIRECTION.DEGRADING
81
+ } else {
82
+ return isPositive ? DIRECTION.DEGRADING : DIRECTION.IMPROVING
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Extract field values from history entries
88
+ *
89
+ * @param {Object[]} entries - History entries
90
+ * @param {string} field - Field path (dot notation supported)
91
+ * @returns {number[]} Array of values
92
+ */
93
+ export function extractValues(entries, field) {
94
+ return entries.map((entry) => {
95
+ const parts = field.split('.')
96
+ let value = entry
97
+ for (const part of parts) {
98
+ value = value?.[part]
99
+ }
100
+ return typeof value === 'number' ? value : 0
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Calculate trend for a specific metric
106
+ *
107
+ * @param {Object[]} entries - History entries (newest first)
108
+ * @param {string} field - Field path to analyze
109
+ * @param {boolean} higherIsBetter - Whether higher values are better
110
+ * @returns {Object} Trend object with direction and changes
111
+ */
112
+ export function calculateMetricTrend(entries, field, higherIsBetter = true) {
113
+ if (!entries || entries.length === 0) {
114
+ return {
115
+ direction: DIRECTION.STABLE,
116
+ current: null,
117
+ change_7d: null,
118
+ change_30d: null,
119
+ avg_7d: null,
120
+ avg_30d: null,
121
+ data_points: 0,
122
+ }
123
+ }
124
+
125
+ const values = extractValues(entries, field)
126
+ const current = values[0] || 0
127
+
128
+ // Get entries by time period
129
+ const entries7d = filterByDays(entries, 7)
130
+ const entries30d = filterByDays(entries, 30)
131
+
132
+ const values7d = extractValues(entries7d, field)
133
+ const values30d = extractValues(entries30d, field)
134
+
135
+ const avg7d = calculateAverage(values7d)
136
+ const avg30d = calculateAverage(values30d)
137
+
138
+ // Calculate changes
139
+ const change7d = values7d.length > 1 ? calculateAbsoluteChange(values7d[values7d.length - 1], current) : null
140
+
141
+ const change30d = values30d.length > 1 ? calculateAbsoluteChange(values30d[values30d.length - 1], current) : null
142
+
143
+ // Determine direction based on 7d vs 30d averages
144
+ let direction = DIRECTION.STABLE
145
+ if (avg30d !== 0 && values7d.length >= 2 && values30d.length >= 2) {
146
+ const percentChange = calculatePercentageChange(avg30d, avg7d)
147
+ direction = determineDirection(percentChange, SIGNIFICANCE_THRESHOLD, higherIsBetter)
148
+ }
149
+
150
+ return {
151
+ direction,
152
+ current: Math.round(current * 100) / 100,
153
+ change_7d: change7d !== null ? Math.round(change7d * 100) / 100 : null,
154
+ change_30d: change30d !== null ? Math.round(change30d * 100) / 100 : null,
155
+ avg_7d: Math.round(avg7d * 100) / 100,
156
+ avg_30d: Math.round(avg30d * 100) / 100,
157
+ data_points: entries.length,
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Calculate all trends from history entries
163
+ *
164
+ * @param {Object[]} entries - History entries (newest first)
165
+ * @param {string} [branch] - Optional branch filter
166
+ * @returns {Object} Complete trends object
167
+ */
168
+ export function calculateTrends(entries, branch = null) {
169
+ // Filter by branch if specified
170
+ const filteredEntries = branch ? filterByBranch(entries, branch) : entries
171
+
172
+ if (!filteredEntries || filteredEntries.length === 0) {
173
+ return {
174
+ score: createEmptyTrend(),
175
+ performance: createEmptyTrend(),
176
+ issues: createEmptyTrend(),
177
+ data: {
178
+ total_runs: 0,
179
+ branch_filter: branch,
180
+ calculated_at: new Date().toISOString(),
181
+ },
182
+ }
183
+ }
184
+
185
+ return {
186
+ score: calculateMetricTrend(filteredEntries, 'score', true),
187
+ performance: calculateMetricTrend(filteredEntries, 'timing.total_ms', false),
188
+ issues: calculateMetricTrend(filteredEntries, 'summary.total_issues', false),
189
+ data: {
190
+ total_runs: filteredEntries.length,
191
+ branch_filter: branch,
192
+ calculated_at: new Date().toISOString(),
193
+ date_range: {
194
+ from: filteredEntries[filteredEntries.length - 1]?.generated_at,
195
+ to: filteredEntries[0]?.generated_at,
196
+ },
197
+ },
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Create an empty trend object
203
+ *
204
+ * @returns {Object} Empty trend structure
205
+ */
206
+ function createEmptyTrend() {
207
+ return {
208
+ direction: DIRECTION.STABLE,
209
+ current: null,
210
+ change_7d: null,
211
+ change_30d: null,
212
+ avg_7d: null,
213
+ avg_30d: null,
214
+ data_points: 0,
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Calculate trends grouped by branch
220
+ *
221
+ * @param {Object[]} entries - History entries
222
+ * @returns {Object} Trends by branch
223
+ */
224
+ export function calculateTrendsByBranch(entries) {
225
+ if (!entries || entries.length === 0) {
226
+ return {}
227
+ }
228
+
229
+ const branches = [...new Set(entries.map((e) => e.branch).filter(Boolean))]
230
+ const result = {}
231
+
232
+ for (const branch of branches) {
233
+ result[branch] = calculateTrends(entries, branch)
234
+ }
235
+
236
+ return result
237
+ }
238
+
239
+ /**
240
+ * Generate time series data for visualization
241
+ *
242
+ * @param {Object[]} entries - History entries
243
+ * @param {string} field - Field to extract
244
+ * @param {number} [limit] - Maximum data points
245
+ * @returns {Object[]} Time series array
246
+ */
247
+ export function generateTimeSeries(entries, field, limit = 30) {
248
+ if (!entries || entries.length === 0) return []
249
+
250
+ const values = extractValues(entries, field)
251
+
252
+ return entries.slice(0, limit).map((entry, index) => ({
253
+ date: entry.generated_at,
254
+ value: values[index],
255
+ run_id: entry.run_id,
256
+ branch: entry.branch,
257
+ }))
258
+ }
259
+
260
+ /**
261
+ * Calculate summary statistics for trend display
262
+ *
263
+ * @param {Object} trends - Trends object from calculateTrends
264
+ * @returns {Object} Summary for display
265
+ */
266
+ export function getTrendSummary(trends) {
267
+ const summaryLines = []
268
+
269
+ if (trends.score.direction !== DIRECTION.STABLE) {
270
+ const emoji = trends.score.direction === DIRECTION.IMPROVING ? '+' : '-'
271
+ const change = trends.score.change_7d !== null ? Math.abs(trends.score.change_7d) : 0
272
+ summaryLines.push(`Score ${trends.score.direction} (${emoji}${change} over 7d)`)
273
+ }
274
+
275
+ if (trends.performance.direction !== DIRECTION.STABLE) {
276
+ const emoji = trends.performance.direction === DIRECTION.IMPROVING ? '-' : '+'
277
+ const change = trends.performance.change_7d !== null ? Math.abs(trends.performance.change_7d / 1000) : 0
278
+ summaryLines.push(`Performance ${trends.performance.direction} (${emoji}${change.toFixed(1)}s over 7d)`)
279
+ }
280
+
281
+ if (trends.issues.direction !== DIRECTION.STABLE) {
282
+ const emoji = trends.issues.direction === DIRECTION.IMPROVING ? '-' : '+'
283
+ const change = trends.issues.change_7d !== null ? Math.abs(trends.issues.change_7d) : 0
284
+ summaryLines.push(`Issues ${trends.issues.direction} (${emoji}${change} over 7d)`)
285
+ }
286
+
287
+ return {
288
+ lines: summaryLines,
289
+ overall:
290
+ summaryLines.length === 0
291
+ ? 'stable'
292
+ : summaryLines.some((l) => l.includes('degrading'))
293
+ ? 'degrading'
294
+ : 'improving',
295
+ }
296
+ }
@@ -482,3 +482,107 @@ export function createMinimalResult({ projectName, passed = true }) {
482
482
  extensions: {},
483
483
  }
484
484
  }
485
+
486
+ /**
487
+ * Get trace ID from environment variables
488
+ *
489
+ * Checks common OpenTelemetry and CI environment variables for trace IDs.
490
+ *
491
+ * @returns {string|null} Trace ID or null if not available
492
+ */
493
+ export function getTraceId() {
494
+ // Direct trace ID from environment
495
+ if (process.env.OTEL_TRACE_ID) {
496
+ return process.env.OTEL_TRACE_ID
497
+ }
498
+
499
+ // W3C traceparent header format: 00-{trace_id}-{span_id}-{flags}
500
+ if (process.env.TRACEPARENT) {
501
+ const parts = process.env.TRACEPARENT.split('-')
502
+ if (parts.length >= 2) {
503
+ return parts[1]
504
+ }
505
+ }
506
+
507
+ // GitHub Actions run ID as fallback trace correlation
508
+ if (process.env.GITHUB_RUN_ID) {
509
+ return `gh-${process.env.GITHUB_RUN_ID}`
510
+ }
511
+
512
+ return null
513
+ }
514
+
515
+ /**
516
+ * Build trace URL from trace ID and endpoint
517
+ *
518
+ * @param {string} traceId - The trace ID
519
+ * @param {string} [endpoint] - OTel endpoint (default: from env)
520
+ * @param {string} [template] - URL template with {trace_id} placeholder
521
+ * @returns {string|null} Trace URL or null
522
+ */
523
+ export function buildTraceUrl(traceId, endpoint = null, template = null) {
524
+ if (!traceId) return null
525
+
526
+ const otelEndpoint = endpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_ENDPOINT
527
+
528
+ // Use custom template if provided
529
+ if (template) {
530
+ return template.replace('{trace_id}', traceId).replace('{endpoint}', otelEndpoint || '')
531
+ }
532
+
533
+ // Use env template if available
534
+ if (process.env.OTEL_TRACE_URL_TEMPLATE) {
535
+ return process.env.OTEL_TRACE_URL_TEMPLATE.replace('{trace_id}', traceId).replace(
536
+ '{endpoint}',
537
+ otelEndpoint || ''
538
+ )
539
+ }
540
+
541
+ // Default Jaeger-style URL if endpoint is known
542
+ if (otelEndpoint) {
543
+ // Extract base URL from endpoint (remove /v1/traces if present)
544
+ const baseUrl = otelEndpoint.replace(/\/v1\/traces\/?$/, '').replace(/\/$/, '')
545
+ return `${baseUrl}/trace/${traceId}`
546
+ }
547
+
548
+ return null
549
+ }
550
+
551
+ /**
552
+ * Build telemetry extension object for ci-result.json
553
+ *
554
+ * @param {string} traceId - The trace ID
555
+ * @param {Object} [options] - Additional options
556
+ * @param {number} [options.spansExported] - Number of spans exported
557
+ * @param {number} [options.errorsRecorded] - Number of errors recorded
558
+ * @param {string} [options.endpoint] - OTel endpoint
559
+ * @returns {Object} Telemetry extension object
560
+ */
561
+ export function buildTelemetryExtension(traceId, options = {}) {
562
+ const { spansExported = 0, errorsRecorded = 0, endpoint = null } = options
563
+
564
+ const extension = {
565
+ trace_id: traceId,
566
+ enabled: true,
567
+ }
568
+
569
+ // Add trace URL if we can build one
570
+ const traceUrl = buildTraceUrl(traceId, endpoint)
571
+ if (traceUrl) {
572
+ extension.trace_url = traceUrl
573
+ }
574
+
575
+ // Add metrics if provided
576
+ if (spansExported > 0 || errorsRecorded > 0) {
577
+ extension.spans_exported = spansExported
578
+ extension.errors_recorded = errorsRecorded
579
+ }
580
+
581
+ // Add endpoint info
582
+ const otelEndpoint = endpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_ENDPOINT
583
+ if (otelEndpoint) {
584
+ extension.collector_endpoint = otelEndpoint
585
+ }
586
+
587
+ return extension
588
+ }
@@ -34,8 +34,14 @@ import {
34
34
  calculateExecutionStats,
35
35
  countIssues,
36
36
  extractComponents,
37
+ getTraceId,
38
+ buildTelemetryExtension,
37
39
  } from './ci-result-utils.mjs'
38
40
 
41
+ // History and trends imports
42
+ import { loadHistory, addToHistory, saveHistory } from './ci-result-history.mjs'
43
+ import { calculateTrends } from './ci-result-trends.mjs'
44
+
39
45
  // Validator imports
40
46
  import { getLicenseCheckResult } from './license-check.mjs'
41
47
  import { getReadmeCheckResult } from './readme-validator.mjs'
@@ -132,6 +138,7 @@ async function runValidators(projectRoot, options = {}) {
132
138
  * @param {Object} input.project - Project information
133
139
  * @param {Object} input.metadata - Additional metadata
134
140
  * @param {Object} input.validators - Validator results (from runValidators)
141
+ * @param {Object} input.trends - Pre-calculated trends (optional)
135
142
  * @returns {Object} CI result object
136
143
  */
137
144
  export function generateCiResult(input) {
@@ -143,6 +150,7 @@ export function generateCiResult(input) {
143
150
  metadata = {},
144
151
  issues = [],
145
152
  validators = {},
153
+ trends = null,
146
154
  } = input
147
155
 
148
156
  const now = new Date().toISOString()
@@ -169,19 +177,49 @@ export function generateCiResult(input) {
169
177
  // Determine exit code
170
178
  const exitCode = executionStats.steps_failed > 0 ? 1 : 0
171
179
 
180
+ // Get trace ID - prefer trace_context from input (shell-generated) over env vars
181
+ const traceContext = input.trace_context || {}
182
+ const traceId = traceContext.trace_id || getTraceId()
183
+
184
+ // Build meta object
185
+ const meta = {
186
+ generated_at: now,
187
+ generator: getGeneratorVersion(),
188
+ run_id: generateRunId(),
189
+ trigger: detectTrigger(),
190
+ branch: getGitBranch(),
191
+ commit: getGitCommit(),
192
+ }
193
+
194
+ // Add trace_id if available
195
+ if (traceId) {
196
+ meta.trace_id = traceId
197
+ }
198
+
199
+ // Build extensions with telemetry if trace_id exists
200
+ const extensions = { ...(metadata.extensions || {}) }
201
+ if (traceId) {
202
+ // Count errors from failed steps
203
+ const errorsRecorded = executionStats.steps_failed || 0
204
+
205
+ extensions.telemetry = buildTelemetryExtension(traceId, {
206
+ errorsRecorded,
207
+ // spans_exported remains 0 until we implement actual OTel export (GAP-004/Q12)
208
+ spansExported: 0,
209
+ })
210
+
211
+ // Add trace source info if available
212
+ if (traceContext.source) {
213
+ extensions.telemetry.trace_source = traceContext.source
214
+ }
215
+ }
216
+
172
217
  // Build result object
173
218
  const result = {
174
219
  $schema: 'https://kabran.dev/schemas/ci-result.v2.json',
175
220
  version: '1.0.0',
176
221
 
177
- meta: {
178
- generated_at: now,
179
- generator: getGeneratorVersion(),
180
- run_id: generateRunId(),
181
- trigger: detectTrigger(),
182
- branch: getGitBranch(),
183
- commit: getGitCommit(),
184
- },
222
+ meta,
185
223
 
186
224
  project: {
187
225
  name: project.name || 'unknown',
@@ -216,7 +254,12 @@ export function generateCiResult(input) {
216
254
  checks,
217
255
  issues,
218
256
  errors,
219
- extensions: metadata.extensions || {},
257
+ extensions,
258
+ }
259
+
260
+ // Add trends if provided
261
+ if (trends) {
262
+ result.trends = trends
220
263
  }
221
264
 
222
265
  return result
@@ -236,6 +279,10 @@ function parseArgs(args) {
236
279
  skipReadme: false,
237
280
  skipEnv: false,
238
281
  skipQualityStandard: false,
282
+ trackHistory: false,
283
+ historyFile: null,
284
+ maxHistoryEntries: 30,
285
+ calculateTrends: false,
239
286
  }
240
287
 
241
288
  for (let i = 0; i < args.length; i++) {
@@ -259,6 +306,16 @@ function parseArgs(args) {
259
306
  options.skipEnv = true
260
307
  } else if (arg === '--skip-quality-standard') {
261
308
  options.skipQualityStandard = true
309
+ } else if (arg === '--track-history') {
310
+ options.trackHistory = true
311
+ } else if (arg === '--history-file') {
312
+ options.historyFile = args[++i]
313
+ options.trackHistory = true
314
+ } else if (arg === '--max-history') {
315
+ options.maxHistoryEntries = parseInt(args[++i], 10) || 30
316
+ } else if (arg === '--calculate-trends') {
317
+ options.calculateTrends = true
318
+ options.trackHistory = true
262
319
  } else if (arg === '--help' || arg === '-h') {
263
320
  console.log(`
264
321
  Usage: generate-ci-result.mjs [options]
@@ -273,6 +330,10 @@ Options:
273
330
  --skip-readme Skip README check when running validators
274
331
  --skip-env Skip env check when running validators
275
332
  --skip-quality-standard Skip quality-standard check when running validators
333
+ --track-history Track result in history file (max 30 entries)
334
+ --history-file <file> Custom history file path (default: docs/quality/ci-result-history.json)
335
+ --max-history <n> Maximum history entries to keep (default: 30)
336
+ --calculate-trends Calculate and include trends from history
276
337
  -h, --help Show this help message
277
338
 
278
339
  Input format:
@@ -362,15 +423,35 @@ async function main() {
362
423
  })
363
424
  }
364
425
 
426
+ // Determine paths
427
+ const outputPath = options.output || resolve(options.projectRoot, 'docs/quality/ci-result.json')
428
+ const historyPath = options.historyFile || resolve(options.projectRoot, 'docs/quality/ci-result-history.json')
429
+
430
+ // Load history and calculate trends if requested
431
+ let history = null
432
+ if (options.trackHistory || options.calculateTrends) {
433
+ history = loadHistory(historyPath)
434
+
435
+ // Calculate trends if requested
436
+ if (options.calculateTrends && history.entries.length > 0) {
437
+ input.trends = calculateTrends(history.entries)
438
+ }
439
+ }
440
+
365
441
  // Generate result
366
442
  const result = generateCiResult(input)
367
443
 
444
+ // Update history with new result
445
+ if (options.trackHistory && history) {
446
+ addToHistory(result, history)
447
+ saveHistory(history, historyPath, options.maxHistoryEntries)
448
+ console.log(`History updated: ${historyPath} (${history.entries.length} entries)`)
449
+ }
450
+
368
451
  // Output
369
452
  if (options.stdout) {
370
453
  console.log(JSON.stringify(result, null, 2))
371
454
  } else {
372
- const outputPath = options.output || resolve(options.projectRoot, 'docs/quality/ci-result.json')
373
-
374
455
  // Ensure directory exists
375
456
  mkdirSync(dirname(outputPath), { recursive: true })
376
457
 
@@ -142,12 +142,48 @@ export function generateComment(comparison, current, baseline) {
142
142
  }
143
143
  }
144
144
 
145
+ // Trends section (if available)
146
+ if (current.trends && current.trends.data?.total_runs > 1) {
147
+ lines.push(`### 📈 Trends`)
148
+ lines.push('')
149
+ lines.push(`| Metric | Direction | Change (7d) | Avg (30d) |`)
150
+ lines.push(`|--------|-----------|-------------|-----------|`)
151
+
152
+ const scoreTrend = current.trends.score
153
+ if (scoreTrend) {
154
+ const dirEmoji = scoreTrend.direction === 'improving' ? '📈' : scoreTrend.direction === 'degrading' ? '📉' : '➡️'
155
+ lines.push(`| Score | ${dirEmoji} ${scoreTrend.direction} | ${scoreTrend.change_7d !== null ? formatDiff(scoreTrend.change_7d) : 'N/A'} | ${scoreTrend.avg_30d || 'N/A'} |`)
156
+ }
157
+
158
+ const perfTrend = current.trends.performance
159
+ if (perfTrend && perfTrend.avg_30d) {
160
+ const dirEmoji = perfTrend.direction === 'improving' ? '📈' : perfTrend.direction === 'degrading' ? '📉' : '➡️'
161
+ const change7d = perfTrend.change_7d !== null ? `${(perfTrend.change_7d / 1000).toFixed(1)}s` : 'N/A'
162
+ lines.push(`| Duration | ${dirEmoji} ${perfTrend.direction} | ${change7d} | ${(perfTrend.avg_30d / 1000).toFixed(1)}s |`)
163
+ }
164
+
165
+ lines.push('')
166
+ lines.push(`<sub>Based on ${current.trends.data.total_runs} runs</sub>`)
167
+ lines.push('')
168
+ }
169
+
145
170
  // Timing info
146
171
  lines.push(`---`)
147
172
  lines.push(`<sub>`)
148
173
  lines.push(`⏱️ Duration: ${current.timing?.total_human || 'N/A'} | `)
149
174
  lines.push(`🔀 Branch: ${current.meta?.branch || 'N/A'} | `)
150
175
  lines.push(`📝 Commit: ${current.meta?.commit || 'N/A'}`)
176
+
177
+ // Add trace link if available
178
+ if (current.meta?.trace_id) {
179
+ const traceUrl = current.extensions?.telemetry?.trace_url
180
+ if (traceUrl) {
181
+ lines.push(` | 🔍 [Trace](${traceUrl})`)
182
+ } else {
183
+ lines.push(` | 🔍 Trace: ${current.meta.trace_id}`)
184
+ }
185
+ }
186
+
151
187
  lines.push(`</sub>`)
152
188
 
153
189
  return lines.join('\n')