sonorance 0.1.0-beta.4.1
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/LICENSE +174 -0
- package/README.md +103 -0
- package/build/icon.png +0 -0
- package/package.json +86 -0
- package/skill/SKILL.md +41 -0
- package/skill/scripts/sonorance.mjs +31 -0
- package/src/azure-monitor.mjs +221 -0
- package/src/cli.mjs +315 -0
- package/src/comment-client.mjs +75 -0
- package/src/engine-default.mjs +136 -0
- package/src/feedback.mjs +151 -0
- package/src/gitignore.mjs +27 -0
- package/src/grammar.mjs +55 -0
- package/src/identity.mjs +126 -0
- package/src/otlp.mjs +166 -0
- package/src/plugins/deliberate/contribute.mjs +28 -0
- package/src/plugins/deliberate/domain.mjs +40 -0
- package/src/plugins/deliberate/frontmatter.mjs +85 -0
- package/src/plugins/deliberate/gitignore.mjs +21 -0
- package/src/plugins/deliberate/kinds.mjs +44 -0
- package/src/plugins/deliberate/markdown.mjs +42 -0
- package/src/plugins/deliberate/paths.mjs +245 -0
- package/src/plugins/deliberate/stages.mjs +27 -0
- package/src/plugins/deliberate/vault.mjs +1043 -0
- package/src/plugins.mjs +91 -0
- package/src/release-config.mjs +2 -0
- package/src/scrubber.mjs +64 -0
- package/src/server/index.mjs +993 -0
- package/src/sources.mjs +80 -0
- package/src/telemetry-schema.mjs +187 -0
- package/src/telemetry.mjs +390 -0
- package/src/ui/active-line.mjs +42 -0
- package/src/ui/app.js +3553 -0
- package/src/ui/at-mention.mjs +67 -0
- package/src/ui/comments-plugin.mjs +107 -0
- package/src/ui/diff-plugin.mjs +73 -0
- package/src/ui/editor.mjs +210 -0
- package/src/ui/index.html +1723 -0
- package/src/ui/md.mjs +233 -0
- package/src/ui/paste-md.mjs +54 -0
- package/src/ui/shell.html +1374 -0
- package/src/ui/slash.mjs +122 -0
- package/src/vault-registry.mjs +150 -0
package/src/sources.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sources.mjs — parse/serialize the project's categorized grounding sources.
|
|
3
|
+
*
|
|
4
|
+
* `.sonorance/sources.md` is a user-editable Markdown source of truth. Stable starter
|
|
5
|
+
* sections teach the evidence model; custom `##` sections also survive CLI rewrites.
|
|
6
|
+
* Each source remains one list item:
|
|
7
|
+
*
|
|
8
|
+
* - <location> — <description>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SOURCE_SECTIONS = [
|
|
12
|
+
{ id: 'product-strategy', title: 'Product & strategy', note: 'Product briefs, roadmaps, objectives, research plans, and decision context.' },
|
|
13
|
+
{ id: 'code-delivery', title: 'Code & delivery', note: 'Codebases, release notes, issue trackers, experiments, incidents, and delivery history.' },
|
|
14
|
+
{ id: 'metrics-data', title: 'Metrics & data', note: 'Analytics, warehouse queries, dashboards, billing data, and operational metrics.' },
|
|
15
|
+
{ id: 'customer-voice', title: 'Customer voice', note: 'Support, interviews, surveys, reviews, research, and sales conversations.' },
|
|
16
|
+
{ id: 'go-to-market', title: 'Go-to-market & public presence', note: 'Websites, documentation, pricing, CRM, campaigns, blogs, and status pages.' },
|
|
17
|
+
{ id: 'other', title: 'Other', note: 'Relevant grounding that does not fit the sections above.' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const slug = (value) => String(value || '').toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
21
|
+
const knownById = new Map(SOURCE_SECTIONS.map(s => [s.id, s]));
|
|
22
|
+
const knownByTitle = new Map(SOURCE_SECTIONS.map(s => [slug(s.title), s]));
|
|
23
|
+
|
|
24
|
+
export function normalizeSourceSection(value) {
|
|
25
|
+
const key = slug(value);
|
|
26
|
+
const known = knownById.get(key) || knownByTitle.get(key);
|
|
27
|
+
return known
|
|
28
|
+
? { section: known.id, sectionTitle: known.title }
|
|
29
|
+
: { section: key || 'other', sectionTitle: String(value || '').trim() || 'Other' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HEADER = `# Sources
|
|
33
|
+
|
|
34
|
+
Grounding for this project. Add one source per line as \`- <location> — <what it is and why it matters>\`.
|
|
35
|
+
Locations may be URLs, local paths, or git URLs. Edit freely; section headings and list items are picked up on the next run.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function parseItem(item) {
|
|
39
|
+
let location = item, description = '';
|
|
40
|
+
const sep = item.match(/\s+(?:—|–|-{1,2})\s+/);
|
|
41
|
+
if (sep) { location = item.slice(0, sep.index); description = item.slice(sep.index + sep[0].length).trim(); }
|
|
42
|
+
location = location.trim().replace(/^<(.*)>$/, '$1').trim();
|
|
43
|
+
return { location, description };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseSources(text) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let current = normalizeSourceSection('other');
|
|
49
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
50
|
+
const heading = /^\s*##\s+(.+?)\s*$/.exec(raw);
|
|
51
|
+
if (heading) { current = normalizeSourceSection(heading[1]); continue; }
|
|
52
|
+
const item = /^\s*[-*]\s+(.+?)\s*$/.exec(raw);
|
|
53
|
+
if (!item) continue;
|
|
54
|
+
const { location, description } = parseItem(item[1]);
|
|
55
|
+
if (location) out.push({ location, description, ...current });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function serializeSources(list) {
|
|
61
|
+
const grouped = new Map(SOURCE_SECTIONS.map(s => [s.id, []]));
|
|
62
|
+
const customTitles = new Map();
|
|
63
|
+
for (const source of list || []) {
|
|
64
|
+
if (!source?.location) continue;
|
|
65
|
+
const normalized = normalizeSourceSection(source.sectionTitle || source.section || 'other');
|
|
66
|
+
const section = knownById.has(normalized.section) ? normalized.section : normalized.section || 'other';
|
|
67
|
+
if (!grouped.has(section)) grouped.set(section, []);
|
|
68
|
+
if (!knownById.has(section)) customTitles.set(section, normalized.sectionTitle);
|
|
69
|
+
grouped.get(section).push(source);
|
|
70
|
+
}
|
|
71
|
+
const blocks = SOURCE_SECTIONS.map(section => {
|
|
72
|
+
const lines = grouped.get(section.id).map(s => `- ${s.location}${s.description ? ` — ${s.description}` : ''}`);
|
|
73
|
+
return `## ${section.title}\n\n_${section.note}_` + (lines.length ? `\n\n${lines.join('\n')}` : '');
|
|
74
|
+
});
|
|
75
|
+
for (const [section, title] of customTitles) {
|
|
76
|
+
const lines = grouped.get(section).map(s => `- ${s.location}${s.description ? ` — ${s.description}` : ''}`);
|
|
77
|
+
blocks.push(`## ${title}\n\n${lines.join('\n')}`);
|
|
78
|
+
}
|
|
79
|
+
return `${HEADER}\n${blocks.join('\n\n')}\n`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry-schema.mjs — the SINGLE SOURCE OF TRUTH for Sonorance's telemetry contract.
|
|
3
|
+
*
|
|
4
|
+
* Everything the product may ever send is described here, once:
|
|
5
|
+
* - TELEMETRY_EVENTS — the fixed, content-free event allow-list (discrete occurrences).
|
|
6
|
+
* - TELEMETRY_METRICS — the aggregate instruments (high-frequency signals rolled up).
|
|
7
|
+
* - TELEMETRY_RESOURCE — the identity/context attributes that ride EVERY signal.
|
|
8
|
+
* - the allow-listed `props` keys + a value guard that keeps content off the wire.
|
|
9
|
+
*
|
|
10
|
+
* The telemetry client (`telemetry.mjs`) enforces the allow-list + value guard at emit time;
|
|
11
|
+
* schema breaches are dropped rather than sent, and tests keep the contract synchronized.
|
|
12
|
+
*
|
|
13
|
+
* On the wire this maps to OpenTelemetry (OTLP): events → log records, metrics → OTel metrics,
|
|
14
|
+
* a session → a trace, each command/stage → a span (see `otlp.mjs`, implementation spec §2.1).
|
|
15
|
+
* There is NO free-text field: the schema itself is the privacy guarantee (never a prompt,
|
|
16
|
+
* completion, file name, path, title, or body — only enums, numbers, and booleans).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Bumped when the shape changes so a backend/collector can tolerate old clients.
|
|
20
|
+
export const TELEMETRY_SCHEMA_VERSION = 3;
|
|
21
|
+
|
|
22
|
+
// ---- events: the fixed vocabulary (discrete, low-frequency occurrences) --------------------
|
|
23
|
+
// Each entry: [name, description]. Kept sorted by category, then name.
|
|
24
|
+
export const TELEMETRY_EVENTS = [
|
|
25
|
+
// lifecycle
|
|
26
|
+
['install.created', 'First run on this install — a fresh anonymous install_id was minted.'],
|
|
27
|
+
['session.start', 'A process/serve session began (the active-user + retention backbone).'],
|
|
28
|
+
['telemetry.enabled', 'The user turned telemetry on (consent granted).'],
|
|
29
|
+
['telemetry.disabled', 'The user turned telemetry off (consent withdrawn).'],
|
|
30
|
+
// project
|
|
31
|
+
['project.init', 'A folder was initialised as a project (sonorance/deliberate init).'],
|
|
32
|
+
['context.ready', 'The project context was filled in (deliberate init prompt fulfilled).'],
|
|
33
|
+
['source.added', 'A grounding source was added.'],
|
|
34
|
+
['source.removed', 'A grounding source was removed.'],
|
|
35
|
+
// case funnel
|
|
36
|
+
['case.created', 'A case was created.'],
|
|
37
|
+
['case.stage.completed', 'A case analysis stage advanced {stage}.'],
|
|
38
|
+
['case.completed', 'A case reached its final analysis stage.'],
|
|
39
|
+
['case.scored', 'A case was scored by the evaluator {verdict, refreshed, independent}; the first score is the case value moment and independence is a quality guardrail.'],
|
|
40
|
+
['prototype.gate.shown', 'The prototype gate was shown for a completed case.'],
|
|
41
|
+
['prototype.built', 'A prototype was produced for a case {surface}.'],
|
|
42
|
+
['prototype.viewed', 'A prototype was opened/viewed.'],
|
|
43
|
+
['prototype.approved', 'A prototype was approved.'],
|
|
44
|
+
// other value moments
|
|
45
|
+
['brief.completed', 'A landscape brief was produced.'],
|
|
46
|
+
['matchup.completed', 'A competitive matchup was produced {refreshed}.'],
|
|
47
|
+
['readout.completed', 'A product readout was produced.'],
|
|
48
|
+
// comment bridge
|
|
49
|
+
['comment.created', 'An in-record comment was created.'],
|
|
50
|
+
['comment.resolved', 'An in-record comment was resolved {revised}.'],
|
|
51
|
+
['address.drained', 'A batch of open comments was drained by the agent {count}.'],
|
|
52
|
+
// app / editor
|
|
53
|
+
['record.opened', 'A semantic record (case/brief/readout/matchup) was opened {kind} — never a raw file read.'],
|
|
54
|
+
['vault.switched', 'The active vault/project was switched.'],
|
|
55
|
+
['serve.started', 'The local editor server started.'],
|
|
56
|
+
['ui.action', 'A named UI affordance was used {area, action, value?} — enums only, never a file name.'],
|
|
57
|
+
['ui.page.viewed', 'A utility page was viewed {page} — inbox, settings, or feedback.'],
|
|
58
|
+
// generic + errors + feedback
|
|
59
|
+
['command.run', 'A CLI/skill command ran {name, ok, duration_ms} — the universal seam event.'],
|
|
60
|
+
['error', 'An error occurred {class, command, stage} — never message content or a stack trace.'],
|
|
61
|
+
['feedback', 'One user-authored, explicitly-submitted feedback record — the only event that may carry user-consented text.'],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// ---- metrics: aggregate instruments (high-frequency signals; only totals/distribution matter) --
|
|
65
|
+
// Each entry: [name, description]. Flushed as one sparse rollup, not one event per occurrence.
|
|
66
|
+
export const TELEMETRY_METRICS = [
|
|
67
|
+
['files_opened', 'Counter — raw file opens/reads in the editor (navigation volume; no names/paths).'],
|
|
68
|
+
['autosaves', 'Counter — autosave/file-write occurrences (no content, no path).'],
|
|
69
|
+
['searches', 'Counter — in-vault searches run (never the query text).'],
|
|
70
|
+
['inp_ms', 'Histogram — interaction-to-next-paint / latency distribution (p50/p75/p95).'],
|
|
71
|
+
['open_tabs', 'Gauge — current open tab count.'],
|
|
72
|
+
['explorer_files', 'Gauge — current Markdown file count in the Explorer; never names or paths.'],
|
|
73
|
+
['explorer_folders', 'Gauge — current visible folder count in the Markdown-only Explorer.'],
|
|
74
|
+
['project_cases', 'Gauge — current Deliberate case count in the anonymous project.'],
|
|
75
|
+
['project_briefs', 'Gauge — current Deliberate brief count in the anonymous project.'],
|
|
76
|
+
['project_readouts', 'Gauge — current Deliberate product readout count in the anonymous project.'],
|
|
77
|
+
['project_matchups', 'Gauge — current Deliberate matchup count in the anonymous project.'],
|
|
78
|
+
['project_open_comments', 'Gauge — current unresolved comment count in the anonymous project.'],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// ---- resource attributes: identity + coarse context that rides EVERY signal ------------------
|
|
82
|
+
// Each entry: [key, description]. These become OTel resource attributes (feedback carries them too).
|
|
83
|
+
export const TELEMETRY_RESOURCE = [
|
|
84
|
+
['telemetry_schema', 'Numeric telemetry contract version, so collectors and dashboards can separate incompatible clients.'],
|
|
85
|
+
['install_id', 'Anonymous, rotatable per-install id (the correlation key ↔ feedback). Pure random, no PII.'],
|
|
86
|
+
['project_key', 'Anonymous, random per-vault key (committed → team-shared). NOT the folder-name slug.'],
|
|
87
|
+
['session_id', 'Ephemeral per-process/serve id (maps to the OTLP trace id). Never persisted.'],
|
|
88
|
+
['product', 'deliberate | sonorance — which product experience emitted the signal.'],
|
|
89
|
+
['version', 'The product version — rides every signal so version adoption/regressions are queryable.'],
|
|
90
|
+
['surface', 'cli | ui — the choke point the signal came from (skill usage rides the cli seam).'],
|
|
91
|
+
['os', 'Coarse OS platform (darwin | linux | win32).'],
|
|
92
|
+
['os_major', 'Coarse OS major version (a number).'],
|
|
93
|
+
['node_major', 'Coarse Node.js major version (a number).'],
|
|
94
|
+
['harness', 'Coarse agent-harness hint (e.g. copilot-cli) | null.'],
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// The combined, tagged rows the grammar doc embeds (one flat list; category in the description).
|
|
98
|
+
export const TELEMETRY_ROWS = [
|
|
99
|
+
...TELEMETRY_EVENTS.map(([n, d]) => [n, `event · ${d}`]),
|
|
100
|
+
...TELEMETRY_METRICS.map(([n, d]) => [n, `metric · ${d}`]),
|
|
101
|
+
...TELEMETRY_RESOURCE.map(([n, d]) => [n, `attribute · ${d}`]),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// ---- derived registries + enforcement --------------------------------------------------------
|
|
105
|
+
export const EVENT_NAMES = new Set(TELEMETRY_EVENTS.map(([n]) => n));
|
|
106
|
+
export const METRIC_NAMES = new Set(TELEMETRY_METRICS.map(([n]) => n));
|
|
107
|
+
export const COUNTER_METRIC_NAMES = new Set(['files_opened', 'autosaves', 'searches']);
|
|
108
|
+
export const HISTOGRAM_METRIC_NAMES = new Set(['inp_ms']);
|
|
109
|
+
export const GAUGE_METRIC_NAMES = new Set([
|
|
110
|
+
'open_tabs', 'explorer_files', 'explorer_folders',
|
|
111
|
+
'project_cases', 'project_briefs', 'project_readouts', 'project_matchups', 'project_open_comments',
|
|
112
|
+
]);
|
|
113
|
+
export const RESOURCE_KEYS = new Set(TELEMETRY_RESOURCE.map(([n]) => n));
|
|
114
|
+
|
|
115
|
+
export const isEvent = (name) => EVENT_NAMES.has(name);
|
|
116
|
+
export const isMetric = (name) => METRIC_NAMES.has(name);
|
|
117
|
+
export const isCounterMetric = (name) => COUNTER_METRIC_NAMES.has(name);
|
|
118
|
+
export const isHistogramMetric = (name) => HISTOGRAM_METRIC_NAMES.has(name);
|
|
119
|
+
export const isGaugeMetric = (name) => GAUGE_METRIC_NAMES.has(name);
|
|
120
|
+
|
|
121
|
+
// The ONLY property keys any event may carry — all low-cardinality enums / numbers / bools.
|
|
122
|
+
// Anything not on this list is dropped before the event is buffered (content can never ride a
|
|
123
|
+
// stray key). Kept deliberately small; extend it (and the doc) when a new typed event needs a key.
|
|
124
|
+
export const ALLOWED_PROP_KEYS = new Set([
|
|
125
|
+
'name', // a command name from the fixed CLI/skill grammar
|
|
126
|
+
'stage', // a pipeline stage enum (frame | shape | launch | …)
|
|
127
|
+
'verdict', // go | no-go | … enum
|
|
128
|
+
'refreshed', // bool (matchup refreshed in place or case re-scored)
|
|
129
|
+
'independent', // bool (score came from an isolated evaluator, not a same-session fallback)
|
|
130
|
+
'revised', // bool (comment resolved with an edit)
|
|
131
|
+
'count', // number
|
|
132
|
+
'kind', // a record kind (case | brief | readout | matchup) — low cardinality
|
|
133
|
+
'surface', // a primary-surface slug (cli | api | mcp | …) for prototypes
|
|
134
|
+
'area', // ui.action area enum
|
|
135
|
+
'action', // ui.action action enum
|
|
136
|
+
'value', // ui.action value enum (e.g. theme name)
|
|
137
|
+
'page', // ui.page.viewed page enum
|
|
138
|
+
'class', // an error CLASS (constructor name), never the message
|
|
139
|
+
'command', // the command an error/telemetry occurred under (fixed grammar token)
|
|
140
|
+
'category', // feedback category enum
|
|
141
|
+
'rating', // feedback sentiment/rating (number | up | down | excited | null)
|
|
142
|
+
'ok', // bool
|
|
143
|
+
'duration_ms', // number
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// Low-cardinality enums for the one named UI-interaction event (`ui.action`). Kept tight so the
|
|
147
|
+
// UI can never smuggle a label/path/free-text through `area`/`action`/`value`.
|
|
148
|
+
export const UI_ACTION_AREAS = new Set(['explorer', 'toc', 'toolbar', 'settings', 'editor']);
|
|
149
|
+
export const UI_ACTION_ACTIONS = new Set([
|
|
150
|
+
'collapse-all', 'comments', 'create-file', 'create-folder', 'delete-file', 'delete-folder',
|
|
151
|
+
'diff', 'expand-all', 'feedback-open', 'mode-change', 'move', 'new-record', 'proto-open',
|
|
152
|
+
'rename', 'search-open', 'theme-change', 'toc-nav', 'vault-switch',
|
|
153
|
+
]);
|
|
154
|
+
export const UI_ACTION_VALUES = new Set(['system', 'light', 'dark', 'doc', 'diff']);
|
|
155
|
+
export const UI_PAGE_NAMES = new Set(['inbox', 'settings', 'feedback']);
|
|
156
|
+
|
|
157
|
+
// Feedback categories + ratings (also the UI panel's fixed choices).
|
|
158
|
+
export const FEEDBACK_CATEGORIES = new Set(['bug', 'idea', 'praise', 'question', 'other']);
|
|
159
|
+
|
|
160
|
+
// A value is safe to send only if it is a boolean, a finite number, null, or a SHORT enum-like
|
|
161
|
+
// string (no spaces beyond a single word/slug, no quotes/paths/newlines). This is the second
|
|
162
|
+
// guard after the key allow-list: it stops a content-y string riding an allow-listed key.
|
|
163
|
+
const ENUMISH = /^[A-Za-z0-9][A-Za-z0-9._\-]{0,39}$/; // ≤40 chars, slug/enum shape, no spaces
|
|
164
|
+
export function isSafeValue(v) {
|
|
165
|
+
if (v == null) return true;
|
|
166
|
+
if (typeof v === 'boolean') return true;
|
|
167
|
+
if (typeof v === 'number') return Number.isFinite(v);
|
|
168
|
+
if (typeof v === 'string') return ENUMISH.test(v);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Keep only allow-listed keys whose values pass the guard. Returns a fresh, content-free object.
|
|
173
|
+
// Unknown keys and unsafe values are dropped silently (defence in depth, not an error path).
|
|
174
|
+
export function sanitizeProps(props) {
|
|
175
|
+
const out = {};
|
|
176
|
+
if (!props || typeof props !== 'object') return out;
|
|
177
|
+
for (const [k, v] of Object.entries(props)) {
|
|
178
|
+
if (!ALLOWED_PROP_KEYS.has(k)) continue;
|
|
179
|
+
if (!isSafeValue(v)) continue;
|
|
180
|
+
if (k === 'area' && !UI_ACTION_AREAS.has(v)) continue;
|
|
181
|
+
if (k === 'action' && !UI_ACTION_ACTIONS.has(v)) continue;
|
|
182
|
+
if (k === 'value' && !UI_ACTION_VALUES.has(v)) continue;
|
|
183
|
+
if (k === 'page' && !UI_PAGE_NAMES.has(v)) continue;
|
|
184
|
+
out[k] = v;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry.mjs — the platform telemetry CLIENT: the client IS the collector.
|
|
3
|
+
*
|
|
4
|
+
* On the user's machine it buffers, enforces the content-free allow-list, batches, and exports
|
|
5
|
+
* over OTLP — never blocking a command or a render (INP/snappiness is a hard priority). Shared by
|
|
6
|
+
* every surface: the `sonorance` CLI and server (and, via the CLI seam, the skills). Design
|
|
7
|
+
* (implementation spec §2):
|
|
8
|
+
*
|
|
9
|
+
* 1. Kill switches at the SOURCE — consent off, DO_NOT_TRACK, CI, SONORANCE_TELEMETRY=off, or a
|
|
10
|
+
* committed `.sonorance/config.json` telemetry:false → emit nothing.
|
|
11
|
+
* 2. Local JSONL FIRST — every event is appended to `~/.sonorance/telemetry.jsonl` (the audit
|
|
12
|
+
* trail + `telemetry show` source) before any network. Files-first: you can read what we send.
|
|
13
|
+
* 3. Allow-list + value guard (telemetry-schema.mjs) — unknown event names and content-y values
|
|
14
|
+
* are dropped, so nothing off-schema can ever reach the wire.
|
|
15
|
+
* 4. Batch-flush, fire-and-forget — flush on a threshold / timer / clean exit with a short
|
|
16
|
+
* timeout; failure is silent (the JSONL keeps the record). High-frequency signals are
|
|
17
|
+
* aggregated into metrics (counters/gauges/histograms), never one event each.
|
|
18
|
+
* 5. Local-test switch — SONORANCE_TELEMETRY=console|debug prints the exact OTLP payloads
|
|
19
|
+
* instead of sending, so the founder can verify the pipeline on a dev machine.
|
|
20
|
+
*
|
|
21
|
+
* On the wire it is OTLP (see otlp.mjs): a session → a trace, events → logs, rollups → metrics.
|
|
22
|
+
*/
|
|
23
|
+
import { appendFile, readFile } from 'node:fs/promises';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
import {
|
|
28
|
+
EVENT_NAMES, isCounterMetric, isHistogramMetric, isGaugeMetric,
|
|
29
|
+
sanitizeProps, TELEMETRY_SCHEMA_VERSION,
|
|
30
|
+
} from './telemetry-schema.mjs';
|
|
31
|
+
import { ensureInstallId, projectKey, ensureProjectKey, sessionId, coarseEnv, getConsent, sonoranceHome } from './identity.mjs';
|
|
32
|
+
import { encodeLogs, encodeMetrics, encodeTraces, traceIdOf, sendOtlp } from './otlp.mjs';
|
|
33
|
+
import { azureConnectionString, azureTrackUrl, encodeEnvelopes, sendAzure, DEFAULT_CONNECTION_STRING } from './azure-monitor.mjs';
|
|
34
|
+
|
|
35
|
+
// Default posture when the user has not yet chosen (transparent opt-out — implementation spec §6,
|
|
36
|
+
// the recommended model). Flip to false here for opt-in; it is the single switch for that policy.
|
|
37
|
+
const DEFAULT_CONSENT_ON = true;
|
|
38
|
+
|
|
39
|
+
// Fixed histogram bounds (ms) for the latency/INP distribution.
|
|
40
|
+
const INP_BOUNDS = [50, 100, 200, 300, 500, 1000, 2000, 5000];
|
|
41
|
+
|
|
42
|
+
const auditPath = () => join(sonoranceHome(), 'telemetry.jsonl');
|
|
43
|
+
|
|
44
|
+
// ---- consent / mode resolution ----------------------------------------------
|
|
45
|
+
// Returns 'off' | 'console' | 'otlp'. Env overrides win (dev/CI + the local-test switch), then the
|
|
46
|
+
// committed team policy, then the user's home-config consent, then the default posture.
|
|
47
|
+
export function resolveMode(vaultDir) {
|
|
48
|
+
const env = String(process.env.SONORANCE_TELEMETRY || '').toLowerCase();
|
|
49
|
+
if (['off', '0', 'false', 'no', 'none'].includes(env)) return 'off';
|
|
50
|
+
if (['console', 'debug', 'print', 'stdout'].includes(env)) return 'console';
|
|
51
|
+
const forcedOn = ['on', '1', 'true', 'otlp'].includes(env);
|
|
52
|
+
if (!forcedOn) {
|
|
53
|
+
if (/^(1|true)$/i.test(String(process.env.DO_NOT_TRACK || ''))) return 'off';
|
|
54
|
+
if (process.env.CI && String(process.env.CI).toLowerCase() !== 'false') return 'off';
|
|
55
|
+
if (vaultDir && committedPolicyOff(vaultDir)) return 'off';
|
|
56
|
+
const consent = getConsent();
|
|
57
|
+
if (consent === false) return 'off';
|
|
58
|
+
if (consent === undefined && !DEFAULT_CONSENT_ON) return 'off';
|
|
59
|
+
}
|
|
60
|
+
return 'otlp';
|
|
61
|
+
}
|
|
62
|
+
// A committed `.sonorance/config.json` `telemetry:false` is a team/enterprise kill switch that
|
|
63
|
+
// overrides an individual's consent (but env `on` can still force it for the founder's own tests).
|
|
64
|
+
function committedPolicyOff(vaultDir) {
|
|
65
|
+
try { return JSON.parse(readFileSync(join(vaultDir, '.sonorance', 'config.json'), 'utf8')).telemetry === false; }
|
|
66
|
+
catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- transport resolution ----------------------------------------------------
|
|
70
|
+
// WHERE a live signal goes (mode decides WHETHER; this decides WHERE). Precedence:
|
|
71
|
+
// 1. an explicit Azure connection string (env) → export straight to that App Insights ("Breeze");
|
|
72
|
+
// 2. an explicit OTLP endpoint (env) → OTLP/HTTP to it (a self-hosted collector / any OTLP
|
|
73
|
+
// backend);
|
|
74
|
+
// 3. the official package's release-configured connection string → zero-config Azure export;
|
|
75
|
+
// 4. nothing configured → local-only: the JSONL audit keeps everything, nothing is sent
|
|
76
|
+
// (fail-closed; the app never blindly POSTs to a probably-dead localhost).
|
|
77
|
+
export function resolveTransport() {
|
|
78
|
+
const envCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || process.env.SONORANCE_AZURE_CONNECTION_STRING;
|
|
79
|
+
if (envCs) { const endpoint = azureTrackUrl(envCs); if (endpoint) return { kind: 'azure', endpoint, label: 'Azure Monitor (Application Insights)' }; }
|
|
80
|
+
const otlp = process.env.SONORANCE_OTLP_ENDPOINT || process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
81
|
+
if (otlp) return { kind: 'otlp', endpoint: otlp.replace(/\/+$/, ''), label: `OTLP → ${otlp}` };
|
|
82
|
+
if (DEFAULT_CONNECTION_STRING) {
|
|
83
|
+
const endpoint = azureTrackUrl(DEFAULT_CONNECTION_STRING);
|
|
84
|
+
if (endpoint) return { kind: 'azure', endpoint, label: 'Azure Monitor (Application Insights, release default)' };
|
|
85
|
+
}
|
|
86
|
+
return { kind: 'none', endpoint: null, label: 'local only (no destination configured)' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build the OTLP [signal, payload] jobs from the neutral drained buffers (the vendor-agnostic wire).
|
|
90
|
+
function otlpJobs({ events, spans, counters, gauges, histograms, window, resource }) {
|
|
91
|
+
const jobs = [];
|
|
92
|
+
if (events.length) jobs.push(['logs', encodeLogs(events.map(e => ({ name: e.name, ts: e.ts, attributes: e.props, traceId: traceIdOf(resource.session_id) })), resource)]);
|
|
93
|
+
if (spans.length) jobs.push(['traces', encodeTraces(spans, resource)]);
|
|
94
|
+
if (counters.length || gauges.length || histograms.length) jobs.push(['metrics', encodeMetrics({
|
|
95
|
+
counters: counters.map(c => ({ ...c, attributes: {} })),
|
|
96
|
+
gauges: gauges.map(g => ({ ...g, attributes: {} })),
|
|
97
|
+
histograms,
|
|
98
|
+
}, resource, window)]);
|
|
99
|
+
return jobs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- the client -------------------------------------------------------------
|
|
103
|
+
function createClient() {
|
|
104
|
+
const state = {
|
|
105
|
+
ctx: { surface: 'cli', product: 'sonorance', version: '0', vaultDir: null },
|
|
106
|
+
resource: null,
|
|
107
|
+
mode: 'off',
|
|
108
|
+
events: [], // pending log records: { name, ts, props }
|
|
109
|
+
spans: [], // pending trace spans
|
|
110
|
+
counters: new Map(), // metricName → integer count
|
|
111
|
+
gauges: new Map(), // metricName → latest current value
|
|
112
|
+
histos: new Map(), // metricName → number[]
|
|
113
|
+
windowStart: Date.now(),
|
|
114
|
+
session: null, // the root session span
|
|
115
|
+
flushTimer: null,
|
|
116
|
+
configured: false,
|
|
117
|
+
generation: 0, // increments when the vault context changes
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const buildResource = () => {
|
|
121
|
+
const { surface, product, version, vaultDir } = state.ctx;
|
|
122
|
+
const { id: install_id } = ensureInstallId();
|
|
123
|
+
// Mint the project_key lazily (only for a real vault) so a random-but-committed key exists.
|
|
124
|
+
const project_key = vaultDir ? (projectKey(vaultDir) || ensureProjectKey(vaultDir)) : null;
|
|
125
|
+
return {
|
|
126
|
+
telemetry_schema: TELEMETRY_SCHEMA_VERSION,
|
|
127
|
+
install_id, project_key, session_id: sessionId(),
|
|
128
|
+
product, version: String(version || '0'), surface,
|
|
129
|
+
...coarseEnv(),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const on = () => state.mode !== 'off';
|
|
134
|
+
|
|
135
|
+
const scheduleFlush = () => {
|
|
136
|
+
if (state.mode === 'off' || state.flushTimer) return;
|
|
137
|
+
state.flushTimer = setTimeout(() => { state.flushTimer = null; flush().catch(() => {}); }, 5000);
|
|
138
|
+
state.flushTimer.unref?.();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Append to the local JSONL audit trail — the offline record you can read. Never awaited on the
|
|
142
|
+
// hot path; failure is ignored (a home-dir write problem must never break a command).
|
|
143
|
+
const audit = (line) => { appendFile(auditPath(), JSON.stringify(line) + '\n').catch(() => {}); };
|
|
144
|
+
|
|
145
|
+
function configure({ surface, product, version, vaultDir } = {}) {
|
|
146
|
+
if (vaultDir !== undefined && vaultDir !== state.ctx.vaultDir) state.generation++;
|
|
147
|
+
if (surface) state.ctx.surface = surface;
|
|
148
|
+
if (product) state.ctx.product = product;
|
|
149
|
+
if (version != null) state.ctx.version = version;
|
|
150
|
+
if (vaultDir !== undefined) state.ctx.vaultDir = vaultDir;
|
|
151
|
+
state.mode = resolveMode(state.ctx.vaultDir);
|
|
152
|
+
state.resource = on() ? buildResource() : null;
|
|
153
|
+
// Open the session trace span once, when first enabled.
|
|
154
|
+
if (on() && !state.session) {
|
|
155
|
+
const traceId = traceIdOf(state.resource.session_id) || randomBytes(16).toString('hex');
|
|
156
|
+
state.session = { name: 'session', traceId, spanId: randomBytes(8).toString('hex'), start: Date.now(), attributes: {} };
|
|
157
|
+
}
|
|
158
|
+
state.configured = true;
|
|
159
|
+
return state.mode;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Re-resolve consent/mode at runtime (after a `telemetry on/off` toggle) without a restart.
|
|
163
|
+
function refresh() {
|
|
164
|
+
const was = on();
|
|
165
|
+
state.mode = resolveMode(state.ctx.vaultDir);
|
|
166
|
+
if (on() && !state.resource) state.resource = buildResource();
|
|
167
|
+
if (!was && on() && !state.session) {
|
|
168
|
+
const traceId = traceIdOf(state.resource.session_id) || randomBytes(16).toString('hex');
|
|
169
|
+
state.session = { name: 'session', traceId, spanId: randomBytes(8).toString('hex'), start: Date.now(), attributes: {} };
|
|
170
|
+
}
|
|
171
|
+
return state.mode;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Emit one discrete event. Off-schema names and content-y props are dropped (never sent).
|
|
175
|
+
function emit(name, props = {}) {
|
|
176
|
+
if (!state.configured) configure();
|
|
177
|
+
if (!on()) return null;
|
|
178
|
+
if (!EVENT_NAMES.has(name)) return null; // not on the allow-list → drop
|
|
179
|
+
const clean = sanitizeProps(props);
|
|
180
|
+
if (name === 'ui.action' && (!clean.area || !clean.action)) return null;
|
|
181
|
+
if (name === 'ui.page.viewed' && !clean.page) return null;
|
|
182
|
+
const event_id = `ev_${randomBytes(8).toString('hex')}`;
|
|
183
|
+
const eventProps = { ...clean, event_id };
|
|
184
|
+
const rec = { type: name, ts: Date.now(), schema: TELEMETRY_SCHEMA_VERSION, event_id, ...state.resource, props: clean };
|
|
185
|
+
audit(rec);
|
|
186
|
+
state.events.push({ name, ts: rec.ts, props: eventProps });
|
|
187
|
+
if (state.events.length >= 50) flush().catch(() => {}); else scheduleFlush();
|
|
188
|
+
return rec;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Aggregate content-free signals: interval counters, current-value gauges, or histogram samples.
|
|
192
|
+
function count(metric, n = 1) {
|
|
193
|
+
if (!state.configured) configure();
|
|
194
|
+
if (!on() || !isCounterMetric(metric)) return;
|
|
195
|
+
state.counters.set(metric, (state.counters.get(metric) || 0) + (Number(n) || 0));
|
|
196
|
+
scheduleFlush();
|
|
197
|
+
}
|
|
198
|
+
function gauge(metric, value) {
|
|
199
|
+
if (!state.configured) configure();
|
|
200
|
+
const v = Number(value);
|
|
201
|
+
if (!on() || !isGaugeMetric(metric) || !Number.isFinite(v) || v < 0) return;
|
|
202
|
+
state.gauges.set(metric, v);
|
|
203
|
+
scheduleFlush();
|
|
204
|
+
}
|
|
205
|
+
function observe(metric, value) {
|
|
206
|
+
if (!state.configured) configure();
|
|
207
|
+
if (!on() || !isHistogramMetric(metric)) return;
|
|
208
|
+
const v = Number(value); if (!Number.isFinite(v)) return;
|
|
209
|
+
if (!state.histos.has(metric)) state.histos.set(metric, []);
|
|
210
|
+
state.histos.get(metric).push(v);
|
|
211
|
+
scheduleFlush();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A command/stage span (a child of the session trace) — the "user journey" tree. Returns a
|
|
215
|
+
// handle whose .end() closes it; a no-op object when telemetry is off.
|
|
216
|
+
function startSpan(name, attributes = {}) {
|
|
217
|
+
if (!state.configured) configure();
|
|
218
|
+
if (!on() || !state.session) return { end() {} };
|
|
219
|
+
const span = {
|
|
220
|
+
name, traceId: state.session.traceId, spanId: randomBytes(8).toString('hex'),
|
|
221
|
+
parentSpanId: state.session.spanId, start: Date.now(), attributes: sanitizeProps(attributes), ok: true,
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
end(extra = {}) {
|
|
225
|
+
span.end = Date.now();
|
|
226
|
+
span.attributes = { ...span.attributes, ...sanitizeProps(extra) };
|
|
227
|
+
if (extra && extra.ok === false) span.ok = false;
|
|
228
|
+
state.spans.push(span);
|
|
229
|
+
scheduleFlush();
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Drain the accumulators into the NEUTRAL rollup shape (transport-agnostic), reset the window.
|
|
235
|
+
function drainRaw() {
|
|
236
|
+
const events = state.events.splice(0);
|
|
237
|
+
const spans = state.spans.splice(0);
|
|
238
|
+
const counters = [...state.counters].map(([name, value]) => ({ name, value }));
|
|
239
|
+
const gauges = [...state.gauges].map(([name, value]) => ({ name, value }));
|
|
240
|
+
const histograms = [...state.histos].map(([name, values]) => histogram(name, values));
|
|
241
|
+
const window = { start: state.windowStart, end: Date.now() };
|
|
242
|
+
state.counters.clear(); state.gauges.clear(); state.histos.clear(); state.windowStart = Date.now();
|
|
243
|
+
return { events, spans, counters, gauges, histograms, window };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Flush every non-empty signal over the ACTIVE transport (Azure Monitor by default; OTLP if an
|
|
247
|
+
// OTLP endpoint is set; local-only otherwise). Fire-and-forget: awaited only so callers CAN block
|
|
248
|
+
// on shutdown, but it never throws and a send failure keeps the audit JSONL as the durable record.
|
|
249
|
+
// Returns a small result summary ({ sent, transport, ok?, status?, error? }) for `telemetry test`.
|
|
250
|
+
async function flush({ requeue = true } = {}) {
|
|
251
|
+
if (state.flushTimer) { clearTimeout(state.flushTimer); state.flushTimer = null; }
|
|
252
|
+
const generation = state.generation;
|
|
253
|
+
if (!on()) { state.events = []; state.spans = []; state.counters.clear(); state.gauges.clear(); state.histos.clear(); return { sent: false, transport: 'off' }; }
|
|
254
|
+
const resource = state.resource || buildResource();
|
|
255
|
+
const { events, spans, counters, gauges, histograms, window } = drainRaw();
|
|
256
|
+
if (!events.length && !spans.length && !counters.length && !gauges.length && !histograms.length) return { sent: false, transport: 'idle' };
|
|
257
|
+
const transport = resolveTransport();
|
|
258
|
+
|
|
259
|
+
// The local-test switch: print exactly what WOULD leave over the active transport, send nothing.
|
|
260
|
+
if (state.mode === 'console') {
|
|
261
|
+
if (transport.kind === 'azure') printPayload('azure', encodeEnvelopes({ events, spans, counters, gauges, histograms, window }, resource));
|
|
262
|
+
else for (const [signal, payload] of otlpJobs({ events, spans, counters, gauges, histograms, window, resource })) printPayload(signal, payload);
|
|
263
|
+
return { sent: false, transport: 'console' };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (transport.kind === 'azure') {
|
|
267
|
+
const r = await sendAzure(encodeEnvelopes({ events, spans, counters, gauges, histograms, window }, resource), { endpoint: transport.endpoint });
|
|
268
|
+
if (r.ok === false && requeue && state.generation === generation) {
|
|
269
|
+
if (events.length) state.events = [...events.slice(-500), ...state.events];
|
|
270
|
+
for (const g of gauges) if (!state.gauges.has(g.name)) state.gauges.set(g.name, g.value);
|
|
271
|
+
}
|
|
272
|
+
return { sent: true, transport: 'azure', ...r };
|
|
273
|
+
}
|
|
274
|
+
if (transport.kind === 'otlp') {
|
|
275
|
+
const jobs = otlpJobs({ events, spans, counters, gauges, histograms, window, resource });
|
|
276
|
+
const results = await Promise.all(jobs.map(([signal, payload]) => sendOtlp(signal, payload, { endpoint: transport.endpoint })));
|
|
277
|
+
const logResult = results[jobs.findIndex(([signal]) => signal === 'logs')];
|
|
278
|
+
const metricResult = results[jobs.findIndex(([signal]) => signal === 'metrics')];
|
|
279
|
+
if (requeue && state.generation === generation && logResult?.ok === false && events.length) state.events = [...events.slice(-500), ...state.events];
|
|
280
|
+
if (requeue && state.generation === generation && metricResult?.ok === false) {
|
|
281
|
+
for (const g of gauges) if (!state.gauges.has(g.name)) state.gauges.set(g.name, g.value);
|
|
282
|
+
}
|
|
283
|
+
const ok = results.every(r => r && r.ok);
|
|
284
|
+
return { sent: true, transport: 'otlp', ok, status: results[0]?.status };
|
|
285
|
+
}
|
|
286
|
+
// transport 'none' → nothing sent; the JSONL audit already holds everything (local-only).
|
|
287
|
+
return { sent: false, transport: 'none' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Close the session span + do a final, time-boxed flush. Called by CLIs before exit and by the
|
|
291
|
+
// server on shutdown. Never blocks longer than `timeoutMs`.
|
|
292
|
+
async function shutdown({ timeoutMs = 800 } = {}) {
|
|
293
|
+
if (state.flushTimer) { clearTimeout(state.flushTimer); state.flushTimer = null; }
|
|
294
|
+
if (on() && state.session) { state.session.end = Date.now(); state.spans.push(state.session); state.session = null; }
|
|
295
|
+
await Promise.race([flush(), new Promise(r => setTimeout(r, timeoutMs))]).catch(() => {});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Introspection for `telemetry show/status/test` + tests: the resolved mode, the active transport,
|
|
299
|
+
// the resource, a record count, and the exact payloads that WOULD be sent now (buffers untouched).
|
|
300
|
+
function preview() {
|
|
301
|
+
const resource = state.resource || buildResource();
|
|
302
|
+
const transport = resolveTransport();
|
|
303
|
+
const events = state.events.slice();
|
|
304
|
+
const spans = state.spans.slice();
|
|
305
|
+
const counters = [...state.counters].map(([name, value]) => ({ name, value }));
|
|
306
|
+
const gauges = [...state.gauges].map(([name, value]) => ({ name, value }));
|
|
307
|
+
const histograms = [...state.histos].map(([name, values]) => histogram(name, values));
|
|
308
|
+
const window = { start: state.windowStart, end: Date.now() };
|
|
309
|
+
const count = events.length + spans.length + counters.length + gauges.length + histograms.length;
|
|
310
|
+
const bufs = { events, spans, counters, gauges, histograms, window, resource };
|
|
311
|
+
const payloads = transport.kind === 'azure'
|
|
312
|
+
? { azure: encodeEnvelopes({ events, spans, counters, gauges, histograms, window }, resource) }
|
|
313
|
+
: Object.fromEntries(otlpJobs(bufs));
|
|
314
|
+
return { mode: state.mode, transport, resource, count, payloads };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
configure, refresh, emit, count, gauge, observe, startSpan, flush, shutdown, preview,
|
|
319
|
+
mode: () => state.mode, resource: () => state.resource, ctx: () => state.ctx,
|
|
320
|
+
_state: state,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- helpers ----------------------------------------------------------------
|
|
325
|
+
function histogram(name, values) {
|
|
326
|
+
const bounds = INP_BOUNDS;
|
|
327
|
+
const buckets = new Array(bounds.length + 1).fill(0);
|
|
328
|
+
let sum = 0, min = null, max = null;
|
|
329
|
+
for (const v of values) {
|
|
330
|
+
sum += v;
|
|
331
|
+
if (min === null || v < min) min = v;
|
|
332
|
+
if (max === null || v > max) max = v;
|
|
333
|
+
let i = bounds.findIndex(b => v <= b);
|
|
334
|
+
if (i < 0) i = bounds.length;
|
|
335
|
+
buckets[i]++;
|
|
336
|
+
}
|
|
337
|
+
return { name, count: values.length, sum, min, max, bounds, buckets, attributes: {} };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function printPayload(signal, payload) {
|
|
341
|
+
// The local-test switch: print exactly what would go over the wire, tagged, to stderr (so it
|
|
342
|
+
// never pollutes a command's stdout / the comment-bridge JSON). `signal` is an OTLP signal name
|
|
343
|
+
// (logs|metrics|traces) or `azure` for the App Insights envelope batch.
|
|
344
|
+
try { process.stderr.write(`[telemetry:${signal}] ${JSON.stringify(payload)}\n`); } catch { /* ignore */ }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- process-wide singleton -------------------------------------------------
|
|
348
|
+
let _client = null;
|
|
349
|
+
export function telemetry() { if (!_client) _client = createClient(); return _client; }
|
|
350
|
+
|
|
351
|
+
// Convenience proxies (most callers just want these).
|
|
352
|
+
export const configureTelemetry = (opts) => telemetry().configure(opts);
|
|
353
|
+
export const refreshTelemetry = () => telemetry().refresh();
|
|
354
|
+
export const emit = (name, props) => telemetry().emit(name, props);
|
|
355
|
+
export const count = (metric, n) => telemetry().count(metric, n);
|
|
356
|
+
export const gauge = (metric, value) => telemetry().gauge(metric, value);
|
|
357
|
+
export const observe = (metric, value) => telemetry().observe(metric, value);
|
|
358
|
+
export const startSpan = (name, attrs) => telemetry().startSpan(name, attrs);
|
|
359
|
+
export const flushTelemetry = (opts) => telemetry().flush(opts);
|
|
360
|
+
export const shutdownTelemetry = (opts) => telemetry().shutdown(opts);
|
|
361
|
+
export const telemetryMode = () => telemetry().mode();
|
|
362
|
+
export const telemetryPreview = () => telemetry().preview();
|
|
363
|
+
export { auditPath };
|
|
364
|
+
|
|
365
|
+
// A browser request is already a bounded batch. Process it with an isolated vault-scoped client
|
|
366
|
+
// so concurrent project tabs can never share resource identity, consent policy, or retry state.
|
|
367
|
+
export async function submitTelemetryBatch({ events = [], counters = {}, gauges = {}, histograms = {} } = {}, opts = {}) {
|
|
368
|
+
const client = createClient();
|
|
369
|
+
client.configure(opts);
|
|
370
|
+
for (const event of events) {
|
|
371
|
+
if (event && typeof event.name === 'string') client.emit(event.name, event.props || {});
|
|
372
|
+
}
|
|
373
|
+
for (const [name, value] of Object.entries(counters || {})) client.count(name, value);
|
|
374
|
+
for (const [name, value] of Object.entries(gauges || {})) client.gauge(name, value);
|
|
375
|
+
for (const [name, values] of Object.entries(histograms || {})) {
|
|
376
|
+
if (Array.isArray(values)) for (const value of values) client.observe(name, value);
|
|
377
|
+
}
|
|
378
|
+
return client.flush({ requeue: false });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Read the last `n` lines of the local audit trail (for `sonorance telemetry show`).
|
|
382
|
+
export async function readAudit(n = 20) {
|
|
383
|
+
try {
|
|
384
|
+
const txt = await readFile(auditPath(), 'utf8');
|
|
385
|
+
return txt.trim().split('\n').filter(Boolean).slice(-n).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
386
|
+
} catch { return []; }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Reset the singleton (tests only).
|
|
390
|
+
export function _resetTelemetryForTest() { _client = null; }
|