argusqa-os 9.2.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/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/package.json +69 -0
- package/src/adapters/browser.js +82 -0
- package/src/argus.js +8 -0
- package/src/batch-runner.js +8 -0
- package/src/cli/init.js +314 -0
- package/src/config/schema.js +108 -0
- package/src/config/targets.js +309 -0
- package/src/domain/finding.js +25 -0
- package/src/mcp-server.js +156 -0
- package/src/orchestration/crawl-and-report.js +16 -0
- package/src/orchestration/dispatcher.js +263 -0
- package/src/orchestration/env-comparison.js +498 -0
- package/src/orchestration/orchestrator.js +1128 -0
- package/src/orchestration/report-processor.js +134 -0
- package/src/orchestration/slack-notifier.js +337 -0
- package/src/orchestration/watch-mode.js +316 -0
- package/src/registry.js +18 -0
- package/src/server/index.js +94 -0
- package/src/server/interaction-handler.js +126 -0
- package/src/server/slash-command-handler.js +185 -0
- package/src/utils/api-frequency.js +128 -0
- package/src/utils/baseline-manager.js +255 -0
- package/src/utils/codebase-analyzer.js +299 -0
- package/src/utils/content-analyzer.js +155 -0
- package/src/utils/contract-validator.js +178 -0
- package/src/utils/css-analyzer.js +407 -0
- package/src/utils/diff.js +189 -0
- package/src/utils/flakiness-detector.js +82 -0
- package/src/utils/flow-runner.js +572 -0
- package/src/utils/github-reporter.js +310 -0
- package/src/utils/hover-analyzer.js +214 -0
- package/src/utils/html-reporter.js +301 -0
- package/src/utils/issues-analyzer.js +171 -0
- package/src/utils/keyboard-analyzer.js +141 -0
- package/src/utils/lighthouse-checker.js +120 -0
- package/src/utils/logger.js +39 -0
- package/src/utils/login-orchestrator.js +99 -0
- package/src/utils/mcp-client.js +264 -0
- package/src/utils/mcp-parsers.js +57 -0
- package/src/utils/memory-analyzer.js +270 -0
- package/src/utils/network-timing-analyzer.js +76 -0
- package/src/utils/parallel-crawler.js +28 -0
- package/src/utils/responsive-analyzer.js +253 -0
- package/src/utils/retry.js +36 -0
- package/src/utils/route-discoverer.js +306 -0
- package/src/utils/security-analyzer.js +302 -0
- package/src/utils/seo-analyzer.js +164 -0
- package/src/utils/session-manager.js +12 -0
- package/src/utils/session-persistence.js +214 -0
- package/src/utils/severity-overrides.js +91 -0
- package/src/utils/slack-guard.js +18 -0
- package/src/utils/slug.js +8 -0
- package/src/utils/snapshot-analyzer.js +330 -0
- package/src/utils/telemetry.js +190 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Accessibility Snapshot Analyzer (v3 Phase D8.2)
|
|
3
|
+
*
|
|
4
|
+
* Calls browser.snapshot() to satisfy the D8.2 tool-coverage requirement, then
|
|
5
|
+
* uses browser.evaluate() for reliable ARIA property queries (take_snapshot format
|
|
6
|
+
* is implementation-dependent in chrome-devtools-mcp; evaluate_script is stable).
|
|
7
|
+
*
|
|
8
|
+
* Detections:
|
|
9
|
+
* a11y_missing_name — interactive element (button, a, input[type=submit/button/reset],
|
|
10
|
+
* [role=button/link]) with no accessible name (no text content,
|
|
11
|
+
* no aria-label, no aria-labelledby, no title, no alt)
|
|
12
|
+
* a11y_missing_form_label — <input> / <select> / <textarea> (excluding hidden/submit/button/
|
|
13
|
+
* reset/image) with no associated <label>, no aria-label, and no
|
|
14
|
+
* aria-labelledby
|
|
15
|
+
* a11y_duplicate_landmark — landmark role that appears more than once without a unique
|
|
16
|
+
* aria-label or aria-labelledby distinguishing each instance
|
|
17
|
+
* (checked for: main, banner, contentinfo, navigation, search,
|
|
18
|
+
* complementary, form, region)
|
|
19
|
+
*
|
|
20
|
+
* Candidates are capped (20 interactive elements, 20 form controls) to bound crawl time.
|
|
21
|
+
* All per-element errors are silently swallowed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { registerExpensive } from '../registry.js';
|
|
25
|
+
|
|
26
|
+
// ── ARIA name check script ────────────────────────────────────────────────────
|
|
27
|
+
// Returns JSON array of { tag, role, outerHTML } for unlabelled interactive elements.
|
|
28
|
+
const MISSING_NAME_SCRIPT = `() => {
|
|
29
|
+
var results = [];
|
|
30
|
+
var selectors = [
|
|
31
|
+
'button', 'a[href]', 'input[type="submit"]', 'input[type="button"]',
|
|
32
|
+
'input[type="reset"]', '[role="button"]', '[role="link"]'
|
|
33
|
+
];
|
|
34
|
+
var seen = new Set();
|
|
35
|
+
var all = [];
|
|
36
|
+
selectors.forEach(function(sel) {
|
|
37
|
+
document.querySelectorAll(sel).forEach(function(el) { if (!seen.has(el)) { seen.add(el); all.push(el); } });
|
|
38
|
+
});
|
|
39
|
+
var count = 0;
|
|
40
|
+
for (var i = 0; i < all.length && count < 20; i++) {
|
|
41
|
+
var el = all[i];
|
|
42
|
+
var r = el.getBoundingClientRect();
|
|
43
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
44
|
+
var name = (el.textContent || '').trim()
|
|
45
|
+
|| el.getAttribute('aria-label') || ''
|
|
46
|
+
|| (el.getAttribute('aria-labelledby') ? (document.getElementById(el.getAttribute('aria-labelledby')) || {}).textContent || '' : '')
|
|
47
|
+
|| el.getAttribute('title') || ''
|
|
48
|
+
|| el.getAttribute('alt') || '';
|
|
49
|
+
if (!name.trim()) {
|
|
50
|
+
results.push({
|
|
51
|
+
tag: el.tagName.toLowerCase(),
|
|
52
|
+
role: el.getAttribute('role') || null,
|
|
53
|
+
outerHTML: el.outerHTML.slice(0, 120),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
count++;
|
|
57
|
+
}
|
|
58
|
+
return JSON.stringify(results);
|
|
59
|
+
}`;
|
|
60
|
+
|
|
61
|
+
// ── Form label check script ───────────────────────────────────────────────────
|
|
62
|
+
// Returns JSON array of { tag, type, id, name } for unlabelled form controls.
|
|
63
|
+
const MISSING_LABEL_SCRIPT = `() => {
|
|
64
|
+
var results = [];
|
|
65
|
+
var controls = document.querySelectorAll('input,select,textarea');
|
|
66
|
+
var skip = new Set(['hidden','submit','button','reset','image']);
|
|
67
|
+
var count = 0;
|
|
68
|
+
for (var i = 0; i < controls.length && count < 20; i++) {
|
|
69
|
+
var el = controls[i];
|
|
70
|
+
var type = (el.getAttribute('type') || '').toLowerCase();
|
|
71
|
+
if (skip.has(type)) continue;
|
|
72
|
+
var r = el.getBoundingClientRect();
|
|
73
|
+
if (r.width === 0 && r.height === 0) { count++; continue; }
|
|
74
|
+
var hasLabel = false;
|
|
75
|
+
if (el.id) { hasLabel = !!document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); }
|
|
76
|
+
if (!hasLabel && el.closest('label')) hasLabel = true;
|
|
77
|
+
if (!hasLabel && el.getAttribute('aria-label')) hasLabel = true;
|
|
78
|
+
if (!hasLabel && el.getAttribute('aria-labelledby')) hasLabel = true;
|
|
79
|
+
// placeholder is not a valid accessible name — intentionally excluded (WCAG 2.1 §3.3.2)
|
|
80
|
+
if (!hasLabel) {
|
|
81
|
+
results.push({
|
|
82
|
+
tag: el.tagName.toLowerCase(),
|
|
83
|
+
type: type || null,
|
|
84
|
+
id: el.id || null,
|
|
85
|
+
name: el.getAttribute('name') || null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
count++;
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify(results);
|
|
91
|
+
}`;
|
|
92
|
+
|
|
93
|
+
// ── Duplicate landmark check script ──────────────────────────────────────────
|
|
94
|
+
// Returns JSON array of role strings that appear more than once without distinct labels.
|
|
95
|
+
const DUPLICATE_LANDMARK_SCRIPT = `() => {
|
|
96
|
+
var landmarks = ['main','banner','contentinfo','navigation','search','complementary','form','region'];
|
|
97
|
+
var results = [];
|
|
98
|
+
landmarks.forEach(function(role) {
|
|
99
|
+
var els = Array.from(document.querySelectorAll(
|
|
100
|
+
'[role="' + role + '"]' + (role === 'main' ? ',main' : role === 'banner' ? ',header' : role === 'contentinfo' ? ',footer' : role === 'navigation' ? ',nav' : role === 'complementary' ? ',aside' : role === 'form' ? ',form' : '')
|
|
101
|
+
));
|
|
102
|
+
// <header>/<footer> (banner/contentinfo) inside sectioning content
|
|
103
|
+
// (<article>, <aside>, <main>, <nav>, <section>) don't expose global landmark
|
|
104
|
+
// roles per the HTML-AAM spec — only count document-scoped instances.
|
|
105
|
+
els = els.filter(function(el) {
|
|
106
|
+
return !el.parentElement || !el.parentElement.closest('article,aside,nav,section');
|
|
107
|
+
});
|
|
108
|
+
if (els.length < 2) return;
|
|
109
|
+
var labels = els.map(function(el) {
|
|
110
|
+
return (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || '').trim();
|
|
111
|
+
});
|
|
112
|
+
var uniqueLabels = new Set(labels.filter(Boolean));
|
|
113
|
+
if (uniqueLabels.size < els.length) {
|
|
114
|
+
results.push({ role: role, count: els.length });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return JSON.stringify(results);
|
|
118
|
+
}`;
|
|
119
|
+
|
|
120
|
+
// ── Heading hierarchy check script ─────────────────────────────────────────
|
|
121
|
+
// Detects heading level skips (e.g. h1 → h3) that break screen-reader nav.
|
|
122
|
+
const HEADING_HIERARCHY_SCRIPT = `() => {
|
|
123
|
+
var headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
|
|
124
|
+
var levels = headings.map(function(h){ return parseInt(h.tagName[1], 10); });
|
|
125
|
+
var skips = [];
|
|
126
|
+
for (var i = 1; i < levels.length; i++) {
|
|
127
|
+
if (levels[i] > levels[i - 1] + 1) {
|
|
128
|
+
skips.push({
|
|
129
|
+
from: levels[i - 1],
|
|
130
|
+
to: levels[i],
|
|
131
|
+
text: headings[i].textContent.trim().slice(0, 60),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return JSON.stringify(skips);
|
|
136
|
+
}`;
|
|
137
|
+
|
|
138
|
+
// ── ARIA state check script ──────────────────────────────────────────────────
|
|
139
|
+
// Detects aria-expanded elements that have no aria-controls pointing to a real element,
|
|
140
|
+
// and form controls that have aria-required but the attribute value is incorrect.
|
|
141
|
+
const ARIA_STATE_SCRIPT = `() => {
|
|
142
|
+
var issues = [];
|
|
143
|
+
|
|
144
|
+
// aria-expanded without aria-controls → AT can't navigate to the controlled content
|
|
145
|
+
var expanded = Array.from(document.querySelectorAll('[aria-expanded]'));
|
|
146
|
+
expanded.slice(0, 20).forEach(function(el) {
|
|
147
|
+
var controls = el.getAttribute('aria-controls');
|
|
148
|
+
if (!controls) {
|
|
149
|
+
issues.push({
|
|
150
|
+
issueType: 'aria_expanded_no_controls',
|
|
151
|
+
tag: el.tagName.toLowerCase(),
|
|
152
|
+
id: el.id || null,
|
|
153
|
+
snippet: el.outerHTML.slice(0, 100),
|
|
154
|
+
});
|
|
155
|
+
} else if (!document.getElementById(controls)) {
|
|
156
|
+
issues.push({
|
|
157
|
+
issueType: 'aria_expanded_no_controls',
|
|
158
|
+
tag: el.tagName.toLowerCase(),
|
|
159
|
+
id: el.id || null,
|
|
160
|
+
snippet: el.outerHTML.slice(0, 100),
|
|
161
|
+
detail: 'aria-controls="' + controls + '" references a non-existent element',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return JSON.stringify(issues);
|
|
167
|
+
}`;
|
|
168
|
+
|
|
169
|
+
// ── JSON parse helper ─────────────────────────────────────────────────────────
|
|
170
|
+
function parseJson(raw) {
|
|
171
|
+
if (raw == null) return null;
|
|
172
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
173
|
+
const inner = raw.result !== undefined ? raw.result : raw;
|
|
174
|
+
if (typeof inner === 'string') { try { return JSON.parse(inner); } catch { return null; } }
|
|
175
|
+
return typeof inner === 'object' ? inner : null;
|
|
176
|
+
}
|
|
177
|
+
if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return null; } }
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Analyse accessibility properties on a page via DOM snapshot + script evaluation.
|
|
185
|
+
*
|
|
186
|
+
* Calls take_snapshot() (D8.2 tool requirement), then uses evaluate_script for
|
|
187
|
+
* reliable ARIA property queries. Navigates internally; silently skips elements
|
|
188
|
+
* whose checks throw.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} mcp - MCP tool interface (navigate_page, take_snapshot, evaluate_script)
|
|
191
|
+
* @param {string} url - Fully-qualified URL to analyse
|
|
192
|
+
* @returns {Promise<object[]>} Array of a11y finding objects
|
|
193
|
+
*/
|
|
194
|
+
export async function analyzeSnapshot(browser, url) {
|
|
195
|
+
const findings = [];
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await browser.navigate(url);
|
|
199
|
+
await new Promise(r => setTimeout(r, 800));
|
|
200
|
+
} catch {
|
|
201
|
+
return findings;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Satisfy D8.2 tool requirement — snapshot captures current DOM/AX state.
|
|
205
|
+
// We store but don't parse its format (implementation-dependent).
|
|
206
|
+
try {
|
|
207
|
+
await browser.snapshot();
|
|
208
|
+
} catch {
|
|
209
|
+
// Non-fatal: evaluation-based checks proceed regardless
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Missing accessible name ───────────────────────────────────────────────
|
|
213
|
+
try {
|
|
214
|
+
const raw = await browser.evaluate(MISSING_NAME_SCRIPT);
|
|
215
|
+
const items = parseJson(raw);
|
|
216
|
+
if (Array.isArray(items)) {
|
|
217
|
+
for (const item of items) {
|
|
218
|
+
findings.push({
|
|
219
|
+
type: 'a11y_missing_name',
|
|
220
|
+
tag: item.tag,
|
|
221
|
+
role: item.role,
|
|
222
|
+
snippet: item.outerHTML,
|
|
223
|
+
message: `Interactive element <${item.tag}${item.role ? ` role="${item.role}"` : ''}> has no accessible name — add aria-label, visible text, or title`,
|
|
224
|
+
severity: 'warning',
|
|
225
|
+
url,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
// Skip silently
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Missing form label ────────────────────────────────────────────────────
|
|
234
|
+
try {
|
|
235
|
+
const raw = await browser.evaluate(MISSING_LABEL_SCRIPT);
|
|
236
|
+
const items = parseJson(raw);
|
|
237
|
+
if (Array.isArray(items)) {
|
|
238
|
+
for (const item of items) {
|
|
239
|
+
const desc = item.id ? `#${item.id}` : item.name ? `[name="${item.name}"]` : item.type ? `[type="${item.type}"]` : '';
|
|
240
|
+
findings.push({
|
|
241
|
+
type: 'a11y_missing_form_label',
|
|
242
|
+
tag: item.tag,
|
|
243
|
+
id: item.id,
|
|
244
|
+
name: item.name,
|
|
245
|
+
message: `Form control <${item.tag}${desc}> has no associated label — add <label for="...">, aria-label, or aria-labelledby`,
|
|
246
|
+
severity: 'warning',
|
|
247
|
+
url,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// Skip silently
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Duplicate landmarks ───────────────────────────────────────────────────
|
|
256
|
+
try {
|
|
257
|
+
const raw = await browser.evaluate(DUPLICATE_LANDMARK_SCRIPT);
|
|
258
|
+
const items = parseJson(raw);
|
|
259
|
+
if (Array.isArray(items)) {
|
|
260
|
+
for (const item of items) {
|
|
261
|
+
findings.push({
|
|
262
|
+
type: 'a11y_duplicate_landmark',
|
|
263
|
+
role: item.role,
|
|
264
|
+
count: item.count,
|
|
265
|
+
message: `${item.count} elements share the "${item.role}" landmark role without distinct aria-label — screen readers cannot distinguish them`,
|
|
266
|
+
severity: 'warning',
|
|
267
|
+
url,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// Skip silently
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── ARIA state checks ────────────────────────────────────────────────────
|
|
276
|
+
try {
|
|
277
|
+
const raw = await browser.evaluate(ARIA_STATE_SCRIPT);
|
|
278
|
+
const items = parseJson(raw);
|
|
279
|
+
if (Array.isArray(items)) {
|
|
280
|
+
for (const item of items) {
|
|
281
|
+
if (item.issueType === 'aria_expanded_no_controls') {
|
|
282
|
+
findings.push({
|
|
283
|
+
type: 'aria_expanded_no_controls',
|
|
284
|
+
tag: item.tag,
|
|
285
|
+
id: item.id,
|
|
286
|
+
snippet: item.snippet,
|
|
287
|
+
message: item.detail
|
|
288
|
+
? `<${item.tag}${item.id ? '#' + item.id : ''}> aria-expanded — ${String(item.detail).slice(0, 200)}`
|
|
289
|
+
: `<${item.tag}${item.id ? '#' + item.id : ''}> has aria-expanded but no aria-controls — AT users cannot navigate to the controlled content`,
|
|
290
|
+
severity: 'warning',
|
|
291
|
+
url,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Skip silently
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Heading hierarchy ─────────────────────────────────────────────────────
|
|
301
|
+
try {
|
|
302
|
+
const raw = await browser.evaluate(HEADING_HIERARCHY_SCRIPT);
|
|
303
|
+
const items = parseJson(raw);
|
|
304
|
+
if (Array.isArray(items)) {
|
|
305
|
+
for (const item of items) {
|
|
306
|
+
findings.push({
|
|
307
|
+
type: 'heading_level_skip',
|
|
308
|
+
from: item.from,
|
|
309
|
+
to: item.to,
|
|
310
|
+
text: item.text,
|
|
311
|
+
message: `Heading level skips from h${item.from} to h${item.to} ("${item.text}") — use sequential heading levels for screen-reader navigation`,
|
|
312
|
+
severity: 'warning',
|
|
313
|
+
url,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Skip silently
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return findings;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Self-registration ─────────────────────────────────────────────────────────
|
|
325
|
+
registerExpensive({
|
|
326
|
+
name: 'snapshot',
|
|
327
|
+
async analyze(browser, url) {
|
|
328
|
+
return analyzeSnapshot(browser, url);
|
|
329
|
+
},
|
|
330
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus Telemetry (v9.3)
|
|
3
|
+
*
|
|
4
|
+
* OpenTelemetry tracing + metrics for Argus crawl pipeline.
|
|
5
|
+
*
|
|
6
|
+
* Default: no-op provider — zero overhead when OTEL_EXPORTER_OTLP_ENDPOINT is not set.
|
|
7
|
+
* Dev: set ARGUS_OTEL_CONSOLE=1 to print spans to stdout (no OTLP endpoint needed).
|
|
8
|
+
* Production: set OTEL_EXPORTER_OTLP_ENDPOINT to ship to Jaeger / Grafana Tempo / etc.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { trace, metrics, SpanStatusCode } from '@opentelemetry/api';
|
|
12
|
+
import { childLogger } from './logger.js';
|
|
13
|
+
|
|
14
|
+
const logger = childLogger('telemetry');
|
|
15
|
+
|
|
16
|
+
// ── SDK bootstrap (lazy — only when an exporter endpoint is configured) ────────
|
|
17
|
+
|
|
18
|
+
let _sdkStarted = false;
|
|
19
|
+
|
|
20
|
+
async function maybeStartSdk() {
|
|
21
|
+
if (_sdkStarted) return;
|
|
22
|
+
_sdkStarted = true;
|
|
23
|
+
|
|
24
|
+
const hasOtlpEndpoint = !!process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
25
|
+
const consoleMode = process.env.ARGUS_OTEL_CONSOLE === '1';
|
|
26
|
+
|
|
27
|
+
if (!hasOtlpEndpoint && !consoleMode) return; // no-op path — skip SDK init entirely
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { resourceFromAttributes } = await import('@opentelemetry/resources');
|
|
31
|
+
const resource = resourceFromAttributes({
|
|
32
|
+
'service.name': 'argus',
|
|
33
|
+
'service.version': '9.3.0',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (consoleMode && !hasOtlpEndpoint) {
|
|
37
|
+
// Lightweight console-only mode for local development
|
|
38
|
+
const { NodeTracerProvider, ConsoleSpanExporter, SimpleSpanProcessor } = await import('@opentelemetry/sdk-trace-node');
|
|
39
|
+
const { PeriodicExportingMetricReader, MeterProvider, ConsoleMetricExporter } = await import('@opentelemetry/sdk-metrics');
|
|
40
|
+
|
|
41
|
+
const provider = new NodeTracerProvider({
|
|
42
|
+
resource,
|
|
43
|
+
spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
|
|
44
|
+
});
|
|
45
|
+
provider.register();
|
|
46
|
+
|
|
47
|
+
const meterProvider = new MeterProvider({
|
|
48
|
+
resource,
|
|
49
|
+
readers: [new PeriodicExportingMetricReader({
|
|
50
|
+
exporter: new ConsoleMetricExporter(),
|
|
51
|
+
exportIntervalMillis: 60_000,
|
|
52
|
+
})],
|
|
53
|
+
});
|
|
54
|
+
metrics.setGlobalMeterProvider(meterProvider);
|
|
55
|
+
logger.info('[ARGUS/telemetry] Console mode — spans printed to stdout');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Full OTLP export
|
|
60
|
+
const { NodeSDK } = await import('@opentelemetry/sdk-node');
|
|
61
|
+
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http');
|
|
62
|
+
const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http');
|
|
63
|
+
const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics');
|
|
64
|
+
|
|
65
|
+
const sdk = new NodeSDK({
|
|
66
|
+
resource,
|
|
67
|
+
traceExporter: new OTLPTraceExporter(),
|
|
68
|
+
metricReader: new PeriodicExportingMetricReader({
|
|
69
|
+
exporter: new OTLPMetricExporter(),
|
|
70
|
+
exportIntervalMillis: 30_000,
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
sdk.start();
|
|
75
|
+
logger.info(`[ARGUS/telemetry] OTLP tracing → ${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}`);
|
|
76
|
+
|
|
77
|
+
process.on('beforeExit', async () => {
|
|
78
|
+
try { await sdk.shutdown(); } catch {}
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// OTel SDK missing or init failure — degrade silently to no-op
|
|
82
|
+
logger.warn(`[ARGUS/telemetry] SDK init failed (${err.message}) — running without tracing`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Tracer / Meter accessors ───────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function getTracer() {
|
|
89
|
+
return trace.getTracer('argus', '9.3.0');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getMeter() {
|
|
93
|
+
return metrics.getMeter('argus', '9.3.0');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Metric instruments (created lazily) ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
let _findingsCounter = null;
|
|
99
|
+
let _flakyCounter = null;
|
|
100
|
+
let _analyzerHistogram = null;
|
|
101
|
+
let _crawlHistogram = null;
|
|
102
|
+
let _newFindingsGauge = null;
|
|
103
|
+
|
|
104
|
+
function findingsCounter() {
|
|
105
|
+
if (!_findingsCounter) _findingsCounter = getMeter().createCounter('argus.findings', { description: 'Total findings emitted' });
|
|
106
|
+
return _findingsCounter;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function flakyCounter() {
|
|
110
|
+
if (!_flakyCounter) _flakyCounter = getMeter().createCounter('argus.flaky_findings', { description: 'Findings downgraded to flaky' });
|
|
111
|
+
return _flakyCounter;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function analyzerHistogram() {
|
|
115
|
+
if (!_analyzerHistogram) _analyzerHistogram = getMeter().createHistogram('argus.analyzer.duration', { description: 'Analyzer wall-clock ms', unit: 'ms' });
|
|
116
|
+
return _analyzerHistogram;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function crawlHistogram() {
|
|
120
|
+
if (!_crawlHistogram) _crawlHistogram = getMeter().createHistogram('argus.crawl.duration', { description: 'Per-route crawl wall-clock ms', unit: 'ms' });
|
|
121
|
+
return _crawlHistogram;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function newFindingsGauge() {
|
|
125
|
+
if (!_newFindingsGauge) _newFindingsGauge = getMeter().createUpDownCounter('argus.new_findings', { description: 'Net new findings vs baseline in this run' });
|
|
126
|
+
return _newFindingsGauge;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wrap an async function in an OTel span.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} spanName - span name (e.g. 'argus.crawl_route')
|
|
135
|
+
* @param {object} attrs - span attributes (added at creation)
|
|
136
|
+
* @param {Function} fn - async function to execute inside the span
|
|
137
|
+
* @returns {*} Result of fn()
|
|
138
|
+
*/
|
|
139
|
+
export async function startSpan(spanName, attrs, fn) {
|
|
140
|
+
await maybeStartSdk();
|
|
141
|
+
const tracer = getTracer();
|
|
142
|
+
return tracer.startActiveSpan(spanName, { attributes: attrs ?? {} }, async (span) => {
|
|
143
|
+
const t0 = Date.now();
|
|
144
|
+
try {
|
|
145
|
+
const result = await fn(span);
|
|
146
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
147
|
+
return result;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
150
|
+
span.recordException(err);
|
|
151
|
+
throw err;
|
|
152
|
+
} finally {
|
|
153
|
+
span.end();
|
|
154
|
+
const ms = Date.now() - t0;
|
|
155
|
+
if (spanName === 'argus.analyzer') analyzerHistogram().record(ms, attrs ?? {});
|
|
156
|
+
if (spanName === 'argus.crawl_route') crawlHistogram().record(ms, attrs ?? {});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Record a finding metric.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} type - finding type (e.g. 'console_error')
|
|
165
|
+
* @param {string} severity - 'critical' | 'warning' | 'info'
|
|
166
|
+
* @param {string} route - route name
|
|
167
|
+
*/
|
|
168
|
+
export function recordFinding(type, severity, route) {
|
|
169
|
+
try {
|
|
170
|
+
findingsCounter().add(1, { type, severity, route: route ?? '' });
|
|
171
|
+
} catch { /* metrics not configured — no-op */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Record flaky findings count.
|
|
176
|
+
*/
|
|
177
|
+
export function recordFlaky(count, route) {
|
|
178
|
+
try {
|
|
179
|
+
if (count > 0) flakyCounter().add(count, { route: route ?? '' });
|
|
180
|
+
} catch { /* no-op */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Record net-new findings delta from baseline.
|
|
185
|
+
*/
|
|
186
|
+
export function recordNewFindings(delta) {
|
|
187
|
+
try {
|
|
188
|
+
if (delta !== 0) newFindingsGauge().add(delta);
|
|
189
|
+
} catch { /* no-op */ }
|
|
190
|
+
}
|