Skip to content

Commit 9d7d4ce

Browse files
committed
feat(telemetry): add export event reader
1 parent dc2e84b commit 9d7d4ce

5 files changed

Lines changed: 624 additions & 0 deletions

File tree

src/telemetry/export/files.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { createReadStream } from "node:fs";
2+
import * as fs from "node:fs/promises";
3+
import * as path from "node:path";
4+
import * as readline from "node:readline";
5+
import { z } from "zod";
6+
7+
import {
8+
isTimestampInRange,
9+
rangeOverlapsUtcDay,
10+
type TelemetryDateRange,
11+
} from "./range";
12+
13+
import type { ExportTelemetryEvent } from "./types";
14+
15+
const TELEMETRY_FILE_PATTERN =
16+
/^telemetry-(\d{4}-\d{2}-\d{2})-([a-zA-Z0-9]+)(?:\.(\d+))?\.jsonl$/;
17+
18+
const StoredTelemetryEventSchema = z.object({
19+
event_id: z.string(),
20+
event_name: z.string(),
21+
timestamp: z.string(),
22+
event_sequence: z.number().finite(),
23+
context: z.object({
24+
extension_version: z.string(),
25+
machine_id: z.string(),
26+
session_id: z.string(),
27+
os_type: z.string(),
28+
os_version: z.string(),
29+
host_arch: z.string(),
30+
platform_name: z.string(),
31+
platform_version: z.string(),
32+
deployment_url: z.string(),
33+
}),
34+
properties: z.record(z.string(), z.string()),
35+
measurements: z.record(z.string(), z.number().finite()),
36+
trace_id: z.string().optional(),
37+
parent_event_id: z.string().optional(),
38+
error: z
39+
.object({
40+
message: z.string(),
41+
type: z.string().optional(),
42+
code: z.string().optional(),
43+
})
44+
.optional(),
45+
});
46+
47+
const ExportTelemetryEventSchema = StoredTelemetryEventSchema.transform(
48+
(event): ExportTelemetryEvent => ({
49+
eventId: event.event_id,
50+
eventName: event.event_name,
51+
timestamp: event.timestamp,
52+
eventSequence: event.event_sequence,
53+
context: {
54+
extensionVersion: event.context.extension_version,
55+
machineId: event.context.machine_id,
56+
sessionId: event.context.session_id,
57+
osType: event.context.os_type,
58+
osVersion: event.context.os_version,
59+
hostArch: event.context.host_arch,
60+
platformName: event.context.platform_name,
61+
platformVersion: event.context.platform_version,
62+
deploymentUrl: event.context.deployment_url,
63+
},
64+
properties: event.properties,
65+
measurements: event.measurements,
66+
...(event.trace_id !== undefined && { traceId: event.trace_id }),
67+
...(event.parent_event_id !== undefined && {
68+
parentEventId: event.parent_event_id,
69+
}),
70+
...(event.error !== undefined && { error: event.error }),
71+
}),
72+
);
73+
74+
type StoredTelemetryEvent = z.infer<typeof StoredTelemetryEventSchema>;
75+
76+
interface TelemetryFileCandidate {
77+
readonly name: string;
78+
readonly date: string;
79+
readonly sessionSlug: string;
80+
readonly segment: number;
81+
}
82+
83+
export async function listTelemetryFilesForRange(
84+
telemetryDir: string,
85+
range: TelemetryDateRange,
86+
): Promise<string[]> {
87+
let names: string[];
88+
try {
89+
names = await fs.readdir(telemetryDir);
90+
} catch (err) {
91+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
92+
return [];
93+
}
94+
throw err;
95+
}
96+
97+
return names
98+
.map((name) => telemetryFileCandidate(name))
99+
.filter(
100+
(candidate): candidate is TelemetryFileCandidate =>
101+
candidate !== undefined && rangeOverlapsUtcDay(candidate.date, range),
102+
)
103+
.sort(compareTelemetryFiles)
104+
.map(({ name }) => path.join(telemetryDir, name));
105+
}
106+
107+
function telemetryFileCandidate(
108+
name: string,
109+
): TelemetryFileCandidate | undefined {
110+
const match = TELEMETRY_FILE_PATTERN.exec(name);
111+
if (!match) {
112+
return undefined;
113+
}
114+
return {
115+
name,
116+
date: match[1],
117+
sessionSlug: match[2],
118+
segment: match[3] === undefined ? 0 : Number(match[3]),
119+
};
120+
}
121+
122+
function compareTelemetryFiles(
123+
a: TelemetryFileCandidate,
124+
b: TelemetryFileCandidate,
125+
): number {
126+
return (
127+
a.date.localeCompare(b.date) ||
128+
a.sessionSlug.localeCompare(b.sessionSlug) ||
129+
a.segment - b.segment ||
130+
a.name.localeCompare(b.name)
131+
);
132+
}
133+
134+
export async function* readTelemetryEvents(
135+
filePaths: readonly string[],
136+
range: TelemetryDateRange,
137+
): AsyncGenerator<ExportTelemetryEvent> {
138+
for (const filePath of filePaths) {
139+
let lineNumber = 0;
140+
const lines = readline.createInterface({
141+
input: createReadStream(filePath, { encoding: "utf8" }),
142+
crlfDelay: Infinity,
143+
});
144+
try {
145+
for await (const line of lines) {
146+
lineNumber += 1;
147+
if (line.trim() === "") {
148+
continue;
149+
}
150+
const event = parseStoredTelemetryEvent(line, filePath, lineNumber);
151+
if (isTimestampInRange(event.timestamp, range)) {
152+
yield event;
153+
}
154+
}
155+
} catch (err) {
156+
throw wrapReadError(err, filePath, lineNumber);
157+
}
158+
}
159+
}
160+
161+
export function parseStoredTelemetryEvent(
162+
line: string,
163+
filePath = "<telemetry>",
164+
lineNumber = 1,
165+
): ExportTelemetryEvent {
166+
try {
167+
return ExportTelemetryEventSchema.parse(JSON.parse(line));
168+
} catch (err) {
169+
throw new Error(
170+
`Failed to parse telemetry file ${path.basename(filePath)}:${lineNumber}: ${errorMessage(err)}`,
171+
{ cause: err },
172+
);
173+
}
174+
}
175+
176+
export function toStoredTelemetryEvent(
177+
event: ExportTelemetryEvent,
178+
): StoredTelemetryEvent {
179+
return {
180+
event_id: event.eventId,
181+
event_name: event.eventName,
182+
timestamp: event.timestamp,
183+
event_sequence: event.eventSequence,
184+
context: {
185+
extension_version: event.context.extensionVersion,
186+
machine_id: event.context.machineId,
187+
session_id: event.context.sessionId,
188+
os_type: event.context.osType,
189+
os_version: event.context.osVersion,
190+
host_arch: event.context.hostArch,
191+
platform_name: event.context.platformName,
192+
platform_version: event.context.platformVersion,
193+
deployment_url: event.context.deploymentUrl,
194+
},
195+
properties: event.properties,
196+
measurements: event.measurements,
197+
...(event.traceId !== undefined && { trace_id: event.traceId }),
198+
...(event.parentEventId !== undefined && {
199+
parent_event_id: event.parentEventId,
200+
}),
201+
...(event.error !== undefined && { error: event.error }),
202+
};
203+
}
204+
205+
function wrapReadError(
206+
err: unknown,
207+
filePath: string,
208+
lineNumber: number,
209+
): Error {
210+
if (err instanceof Error && err.message.includes(path.basename(filePath))) {
211+
return err;
212+
}
213+
const location = lineNumber > 0 ? `:${lineNumber}` : "";
214+
return new Error(
215+
`Failed to read telemetry file ${path.basename(filePath)}${location}: ${errorMessage(err)}`,
216+
{ cause: err },
217+
);
218+
}
219+
220+
function errorMessage(err: unknown): string {
221+
return err instanceof z.ZodError
222+
? z.prettifyError(err)
223+
: err instanceof Error
224+
? err.message
225+
: String(err);
226+
}

src/telemetry/export/range.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { z } from "zod";
2+
3+
const DAY_MS = 24 * 60 * 60 * 1000;
4+
const UtcDateSchema = z.iso.date();
5+
6+
export type TelemetryRangePresetId =
7+
| "last24Hours"
8+
| "last7Days"
9+
| "last30Days"
10+
| "allTime";
11+
12+
export interface TelemetryDateRange {
13+
readonly label: string;
14+
readonly filenamePart: string;
15+
readonly startMs?: number;
16+
readonly endMs?: number;
17+
}
18+
19+
export interface TelemetryRangePreset {
20+
readonly id: TelemetryRangePresetId;
21+
readonly label: string;
22+
readonly detail: string;
23+
}
24+
25+
export const TELEMETRY_RANGE_PRESETS: readonly TelemetryRangePreset[] = [
26+
{
27+
id: "last24Hours",
28+
label: "Last 24 hours",
29+
detail: "Export telemetry from the last day.",
30+
},
31+
{
32+
id: "last7Days",
33+
label: "Last 7 days",
34+
detail: "Export telemetry from the last week.",
35+
},
36+
{
37+
id: "last30Days",
38+
label: "Last 30 days",
39+
detail: "Export telemetry from the last month.",
40+
},
41+
{
42+
id: "allTime",
43+
label: "All time",
44+
detail: "Export all stored telemetry.",
45+
},
46+
];
47+
48+
export function createPresetDateRange(
49+
id: TelemetryRangePresetId,
50+
now: Date = new Date(),
51+
): TelemetryDateRange {
52+
const endMs = now.getTime();
53+
switch (id) {
54+
case "last24Hours":
55+
return {
56+
label: "Last 24 hours",
57+
filenamePart: "last-24-hours",
58+
startMs: endMs - DAY_MS,
59+
endMs,
60+
};
61+
case "last7Days":
62+
return {
63+
label: "Last 7 days",
64+
filenamePart: "last-7-days",
65+
startMs: endMs - 7 * DAY_MS,
66+
endMs,
67+
};
68+
case "last30Days":
69+
return {
70+
label: "Last 30 days",
71+
filenamePart: "last-30-days",
72+
startMs: endMs - 30 * DAY_MS,
73+
endMs,
74+
};
75+
case "allTime":
76+
return {
77+
label: "All time",
78+
filenamePart: "all-time",
79+
};
80+
}
81+
}
82+
83+
export function createCustomDateRange(
84+
startDate: string,
85+
endDate: string,
86+
): TelemetryDateRange {
87+
const startMs = parseUtcDate(startDate);
88+
const endStartMs = parseUtcDate(endDate);
89+
if (endStartMs < startMs) {
90+
throw new Error("End date must be on or after start date.");
91+
}
92+
return {
93+
label: `${startDate} to ${endDate}`,
94+
filenamePart: `${startDate}_to_${endDate}`,
95+
startMs,
96+
endMs: endStartMs + DAY_MS,
97+
};
98+
}
99+
100+
export function validateUtcDateInput(value: string): string | undefined {
101+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
102+
return "Use YYYY-MM-DD.";
103+
}
104+
return UtcDateSchema.safeParse(value).success
105+
? undefined
106+
: "Enter a valid calendar date.";
107+
}
108+
109+
export function parseUtcDate(value: string): number {
110+
try {
111+
const [year, month, day] = UtcDateSchema.parse(value)
112+
.split("-")
113+
.map(Number);
114+
return Date.UTC(year, month - 1, day);
115+
} catch (err) {
116+
throw new Error(`Invalid date '${value}'. Use YYYY-MM-DD.`, { cause: err });
117+
}
118+
}
119+
120+
export function utcDateString(ms: number): string {
121+
return new Date(ms).toISOString().slice(0, 10);
122+
}
123+
124+
export function isTimestampInRange(
125+
timestamp: string,
126+
range: TelemetryDateRange,
127+
): boolean {
128+
const ms = Date.parse(timestamp);
129+
if (!Number.isFinite(ms)) {
130+
throw new Error(`Invalid telemetry timestamp '${timestamp}'.`);
131+
}
132+
return (
133+
(range.startMs === undefined || ms >= range.startMs) &&
134+
(range.endMs === undefined || ms < range.endMs)
135+
);
136+
}
137+
138+
export function rangeOverlapsUtcDay(
139+
date: string,
140+
range: TelemetryDateRange,
141+
): boolean {
142+
if (range.startMs === undefined && range.endMs === undefined) {
143+
return true;
144+
}
145+
const startDate =
146+
range.startMs === undefined ? undefined : utcDateString(range.startMs);
147+
const endDate =
148+
range.endMs === undefined ? undefined : utcDateString(range.endMs - 1);
149+
return (
150+
(startDate === undefined || date >= startDate) &&
151+
(endDate === undefined || date <= endDate)
152+
);
153+
}

0 commit comments

Comments
 (0)