@kabran-tecnologia/kabran-config 1.6.0 → 1.7.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.
- package/README.md +283 -0
- package/package.json +63 -8
- package/src/schemas/ci-result.v2.schema.json +125 -0
- package/src/scripts/ci/ci-runner.sh +85 -0
- package/src/scripts/ci-result-history.mjs +245 -0
- package/src/scripts/ci-result-trends.mjs +296 -0
- package/src/scripts/ci-result-utils.mjs +104 -0
- package/src/scripts/generate-ci-result.mjs +79 -11
- package/src/scripts/pr-quality-comment.mjs +36 -0
- package/src/scripts/setup.mjs +91 -4
- package/src/telemetry/config/defaults.mjs +421 -0
- package/src/telemetry/config/index.mjs +132 -0
- package/src/telemetry/edge/index.mjs +446 -0
- package/src/telemetry/frontend/index.mjs +366 -0
- package/src/telemetry/logger/index.mjs +236 -0
- package/src/telemetry/node/index.mjs +386 -0
- package/src/telemetry/shared/helpers.mjs +133 -0
- package/src/telemetry/shared/index.mjs +15 -0
- package/src/telemetry/shared/types.d.ts +123 -0
- package/templates/telemetry/.env.telemetry.example +118 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI Result History Module
|
|
3
|
+
*
|
|
4
|
+
* Manages historical CI run data for trend analysis.
|
|
5
|
+
* Stores up to 30 recent runs in docs/quality/ci-result-history.json.
|
|
6
|
+
*
|
|
7
|
+
* @module ci-result-history
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
11
|
+
import { dirname } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default maximum number of history entries to keep
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_MAX_ENTRIES = 30
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract a minimal history entry from a full CI result
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} result - Full CI result object
|
|
22
|
+
* @returns {Object} Minimal history entry
|
|
23
|
+
*/
|
|
24
|
+
export function extractHistoryEntry(result) {
|
|
25
|
+
return {
|
|
26
|
+
run_id: result.meta?.run_id || 'unknown',
|
|
27
|
+
generated_at: result.meta?.generated_at || new Date().toISOString(),
|
|
28
|
+
branch: result.meta?.branch || 'unknown',
|
|
29
|
+
commit: result.meta?.commit || 'unknown',
|
|
30
|
+
score: result.summary?.score ?? 0,
|
|
31
|
+
status: result.summary?.status || 'unknown',
|
|
32
|
+
timing: {
|
|
33
|
+
total_ms: result.timing?.total_ms || 0,
|
|
34
|
+
total_human: result.timing?.total_human || '0ms',
|
|
35
|
+
},
|
|
36
|
+
summary: {
|
|
37
|
+
exit_code: result.summary?.exit_code ?? 0,
|
|
38
|
+
total_issues: result.summary?.total_issues ?? 0,
|
|
39
|
+
blocking: result.summary?.blocking ?? 0,
|
|
40
|
+
warnings: result.summary?.warnings ?? 0,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load history from file
|
|
47
|
+
*
|
|
48
|
+
* @param {string} filePath - Path to history file
|
|
49
|
+
* @returns {Object} History object with entries array and metadata
|
|
50
|
+
*/
|
|
51
|
+
export function loadHistory(filePath) {
|
|
52
|
+
if (!existsSync(filePath)) {
|
|
53
|
+
return {
|
|
54
|
+
$schema: 'https://kabran.dev/schemas/ci-result-history.json',
|
|
55
|
+
version: '1.0.0',
|
|
56
|
+
meta: {
|
|
57
|
+
created_at: new Date().toISOString(),
|
|
58
|
+
updated_at: new Date().toISOString(),
|
|
59
|
+
max_entries: DEFAULT_MAX_ENTRIES,
|
|
60
|
+
},
|
|
61
|
+
entries: [],
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(filePath, 'utf8')
|
|
67
|
+
const history = JSON.parse(content)
|
|
68
|
+
|
|
69
|
+
// Ensure entries is an array
|
|
70
|
+
if (!Array.isArray(history.entries)) {
|
|
71
|
+
history.entries = []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return history
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// If file is corrupted, start fresh
|
|
77
|
+
return {
|
|
78
|
+
$schema: 'https://kabran.dev/schemas/ci-result-history.json',
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
meta: {
|
|
81
|
+
created_at: new Date().toISOString(),
|
|
82
|
+
updated_at: new Date().toISOString(),
|
|
83
|
+
max_entries: DEFAULT_MAX_ENTRIES,
|
|
84
|
+
error: `Failed to load: ${error.message}`,
|
|
85
|
+
},
|
|
86
|
+
entries: [],
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Prune history to maximum number of entries
|
|
93
|
+
*
|
|
94
|
+
* @param {Array} entries - History entries
|
|
95
|
+
* @param {number} maxEntries - Maximum entries to keep
|
|
96
|
+
* @returns {Array} Pruned entries (most recent first)
|
|
97
|
+
*/
|
|
98
|
+
export function pruneHistory(entries, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
99
|
+
if (!Array.isArray(entries)) {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sort by generated_at descending (most recent first)
|
|
104
|
+
const sorted = [...entries].sort((a, b) => {
|
|
105
|
+
const dateA = new Date(a.generated_at || 0)
|
|
106
|
+
const dateB = new Date(b.generated_at || 0)
|
|
107
|
+
return dateB - dateA
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Keep only the most recent entries
|
|
111
|
+
return sorted.slice(0, maxEntries)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Add a CI result to history
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} result - Full CI result object
|
|
118
|
+
* @param {Object} history - Existing history object
|
|
119
|
+
* @returns {Object} Updated history object
|
|
120
|
+
*/
|
|
121
|
+
export function addToHistory(result, history) {
|
|
122
|
+
const entry = extractHistoryEntry(result)
|
|
123
|
+
|
|
124
|
+
// Check for duplicate run_id
|
|
125
|
+
const existingIndex = history.entries.findIndex((e) => e.run_id === entry.run_id)
|
|
126
|
+
|
|
127
|
+
if (existingIndex >= 0) {
|
|
128
|
+
// Update existing entry
|
|
129
|
+
history.entries[existingIndex] = entry
|
|
130
|
+
} else {
|
|
131
|
+
// Add new entry at the beginning
|
|
132
|
+
history.entries.unshift(entry)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Update metadata
|
|
136
|
+
history.meta = {
|
|
137
|
+
...history.meta,
|
|
138
|
+
updated_at: new Date().toISOString(),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return history
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Save history to file
|
|
146
|
+
*
|
|
147
|
+
* @param {Object} history - History object
|
|
148
|
+
* @param {string} filePath - Path to save
|
|
149
|
+
* @param {number} maxEntries - Maximum entries to keep
|
|
150
|
+
*/
|
|
151
|
+
export function saveHistory(history, filePath, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
152
|
+
// Prune before saving
|
|
153
|
+
history.entries = pruneHistory(history.entries, maxEntries)
|
|
154
|
+
history.meta.max_entries = maxEntries
|
|
155
|
+
history.meta.entry_count = history.entries.length
|
|
156
|
+
|
|
157
|
+
// Ensure directory exists
|
|
158
|
+
const dir = dirname(filePath)
|
|
159
|
+
if (!existsSync(dir)) {
|
|
160
|
+
mkdirSync(dir, { recursive: true })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Write file
|
|
164
|
+
writeFileSync(filePath, JSON.stringify(history, null, 2) + '\n')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get entries for a specific branch
|
|
169
|
+
*
|
|
170
|
+
* @param {Array} entries - History entries
|
|
171
|
+
* @param {string} branch - Branch name to filter
|
|
172
|
+
* @returns {Array} Filtered entries
|
|
173
|
+
*/
|
|
174
|
+
export function filterByBranch(entries, branch) {
|
|
175
|
+
if (!branch) return entries
|
|
176
|
+
return entries.filter((e) => e.branch === branch)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get entries within a date range
|
|
181
|
+
*
|
|
182
|
+
* @param {Array} entries - History entries
|
|
183
|
+
* @param {number} days - Number of days to include
|
|
184
|
+
* @returns {Array} Filtered entries
|
|
185
|
+
*/
|
|
186
|
+
export function filterByDays(entries, days) {
|
|
187
|
+
if (!days || days <= 0) return entries
|
|
188
|
+
|
|
189
|
+
const cutoff = new Date()
|
|
190
|
+
cutoff.setDate(cutoff.getDate() - days)
|
|
191
|
+
|
|
192
|
+
return entries.filter((e) => {
|
|
193
|
+
const date = new Date(e.generated_at)
|
|
194
|
+
return date >= cutoff
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get statistics from history
|
|
200
|
+
*
|
|
201
|
+
* @param {Array} entries - History entries
|
|
202
|
+
* @returns {Object} Statistics object
|
|
203
|
+
*/
|
|
204
|
+
export function getHistoryStats(entries) {
|
|
205
|
+
if (!entries || entries.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
total_runs: 0,
|
|
208
|
+
oldest_run: null,
|
|
209
|
+
newest_run: null,
|
|
210
|
+
unique_branches: 0,
|
|
211
|
+
branches: [],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sorted = [...entries].sort((a, b) => {
|
|
216
|
+
return new Date(a.generated_at) - new Date(b.generated_at)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const branches = [...new Set(entries.map((e) => e.branch).filter(Boolean))]
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
total_runs: entries.length,
|
|
223
|
+
oldest_run: sorted[0]?.generated_at || null,
|
|
224
|
+
newest_run: sorted[sorted.length - 1]?.generated_at || null,
|
|
225
|
+
unique_branches: branches.length,
|
|
226
|
+
branches,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Process a CI result and update history
|
|
232
|
+
*
|
|
233
|
+
* Convenience function that loads, updates, and saves history.
|
|
234
|
+
*
|
|
235
|
+
* @param {Object} result - Full CI result object
|
|
236
|
+
* @param {string} historyPath - Path to history file
|
|
237
|
+
* @param {number} maxEntries - Maximum entries to keep
|
|
238
|
+
* @returns {Object} Updated history object
|
|
239
|
+
*/
|
|
240
|
+
export function processHistory(result, historyPath, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
241
|
+
const history = loadHistory(historyPath)
|
|
242
|
+
addToHistory(result, history)
|
|
243
|
+
saveHistory(history, historyPath, maxEntries)
|
|
244
|
+
return history
|
|
245
|
+
}
|
|
@@ -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
|
+
}
|