Repository navigation
Expand file tree
/
Copy pathmonitoring.ts
More file actions
297 lines (283 loc) · 10.7 KB
/
Copy pathmonitoring.ts
File metadata and controls
297 lines (283 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import * as Alchemy from "alchemy";
import * as Axiom from "@alchemy.run/axiom";
import * as Output from "alchemy/Output";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { relayResourceNameForStage } from "../deploymentConfig.ts";
import { notifierConfig } from "../monitoring.ts";
import { isPreviewStage } from "../previewStages.ts";
const RUNBOOK_URL =
"https://github.com/pingdotgg/t3code/blob/main/docs/operations/relay-observability.md#mobile-crashes-and-errors";
// A new dataset has none of these fields until a report carries them, and an
// unknown field fails the whole query, so every one has a default.
const field = (name: string, fallback: string) => `column_ifexists('${name}', ${fallback})`;
const reports = (dataset: string) =>
[
`['${dataset}']`,
`| extend source = tostring(${field("attributes.expo.error.source", "''")}),`,
` fatal = tobool(${field("attributes.expo.error.is_fatal", "false")}),`,
` js_fatal = tobool(${field("attributes.relay.crash.js_fatal", "false")}),`,
` platform = tostring(${field("resource.os.name", "''")}),`,
` install = tostring(${field("resource.expo.eas_client.id", "''")}),`,
` error_type = tostring(${field("attributes.exception.type", "''")}),`,
` message = substring(tostring(${field("attributes.exception.message", "''")}), 0, 140),`,
` version = strcat(tostring(${field("service.version", "''")}), ' (', tostring(${field("resource.expo.app.build_number", "''")}), ')')`,
`| where isnotempty(source) and isnotempty(platform)`,
// A fatal JS error also ends in a native abort; counting both would double every JS crash.
`| extend kind = case(source == 'nativeCrash' and js_fatal, 'abort after JS crash', source == 'nativeCrash', 'native crash', fatal, 'JS crash', 'handled error')`,
].join("\n");
const crashes = (dataset: string) =>
`${reports(dataset)}\n| where kind in ('JS crash', 'native crash')`;
const handledErrors = (dataset: string) => `${reports(dataset)}\n| where kind == 'handled error'`;
const ingest = (traces: string) =>
`['${traces}']\n| where name == 'relay.mobile_telemetry.ingest'\n| extend signal = tostring(['attributes.custom']['relay.mobile_telemetry.signal']), outcome = tostring(['attributes.custom']['relay.mobile_telemetry.outcome'])`;
type Severity = "page" | "warn";
interface Datasets {
readonly logs: string;
readonly traces: string;
}
interface MobileMonitor {
readonly id: string;
readonly severity: Severity;
readonly name: string;
/** Why it fires. The runbook link is appended. */
readonly description: string;
readonly query: (datasets: Datasets) => string;
readonly props: Pick<
Axiom.MonitorProps,
| "type"
| "operator"
| "threshold"
| "rangeMinutes"
| "intervalMinutes"
| "compareDays"
| "tolerance"
| "alertOnNoData"
| "triggerFromNRuns"
>;
}
// No production baseline exists yet: the spike monitor learns its own, and the
// thresholds below are starting points to tune after two weeks of data.
const mobileMonitors: ReadonlyArray<MobileMonitor> = [
{
id: "MobileCrashSpikeMonitor",
severity: "page",
name: "T3 Code mobile: crash spike",
description:
"Crashes (JS and native, each counted once) per 15 min well above the same time over the last 7 days. Usually a bad OTA update or store build: compare app versions on the dashboard.",
query: ({ logs }) => `${crashes(logs)}\n| summarize crashes = count() by bin_auto(_time)`,
props: {
type: "AnomalyDetection",
operator: "Above",
compareDays: 7,
tolerance: 50,
rangeMinutes: 15,
intervalMinutes: 15,
},
},
{
id: "MobileCrashSpreadMonitor",
severity: "page",
name: "T3 Code mobile: one crash hitting many installs",
description:
"The most widespread crash reached this many installs in an hour. Its message and versions are at the top of the dashboard's Top crashes table.",
query: ({ logs }) =>
`${crashes(logs)}\n| summarize installs = dcount(install) by platform, error_type, message\n| summarize worst = max(installs)`,
props: {
type: "Threshold",
operator: "AboveOrEqual",
threshold: 25,
rangeMinutes: 60,
intervalMinutes: 15,
},
},
{
id: "MobileTelemetryStoppedMonitor",
severity: "warn",
name: "T3 Code mobile: reports stopped",
description:
"No startup metrics forwarded in an hour. Every launch sends some, so the relay route, the Axiom token, or the app's endpoint is broken, and crashes go unseen too.",
query: ({ traces }) =>
`${ingest(traces)}\n| where signal == 'metrics' and outcome == 'forwarded'\n| summarize forwarded = count()`,
props: {
type: "Threshold",
operator: "Below",
threshold: 1,
rangeMinutes: 60,
intervalMinutes: 15,
alertOnNoData: true,
triggerFromNRuns: 2,
},
},
{
id: "MobileSymbolicationFailingMonitor",
severity: "warn",
name: "T3 Code mobile: crash stacks not symbolicated",
description:
"Share of release JS exceptions over 6 h whose stack stayed minified. Map uploads are failing or the relay cannot read them; relay.symbolication.status says which.",
query: ({ logs }) =>
[
`['${logs}']`,
`| extend status = tostring(${field("attributes.relay.symbolication.status", "''")})`,
`| where isnotempty(status) and status !in ('no_bundle_id', 'no_bundle_frames')`,
`| summarize total = count(), minified = countif(status != 'symbolicated')`,
`| project minified_pct = iff(total >= 20, 100.0 * minified / total, 0.0)`,
].join("\n"),
props: {
type: "Threshold",
operator: "Above",
threshold: 50,
rangeMinutes: 360,
intervalMinutes: 60,
},
},
];
interface QueryChart {
readonly id: string;
readonly name: string;
readonly type: "TimeSeries" | "Table" | "Statistic";
readonly query: { readonly apl: string };
/** Grid columns out of 12. */
readonly width: 4 | 6 | 12;
}
const chart = (
type: QueryChart["type"],
width: QueryChart["width"],
id: string,
name: string,
apl: string,
): QueryChart => ({ id, name, type, width, query: { apl } });
const mobileDashboardCharts = ({ logs, traces }: Datasets): ReadonlyArray<QueryChart> => [
chart("Statistic", 4, "crashes", "Crashes", `${crashes(logs)}\n| summarize count()`),
chart(
"Statistic",
4,
"crashed-installs",
"Installs that crashed",
`${crashes(logs)}\n| summarize dcount(install)`,
),
chart(
"Statistic",
4,
"handled-errors",
"Handled errors",
`${handledErrors(logs)}\n| summarize count()`,
),
chart(
"TimeSeries",
6,
"crashes-over-time",
"Crashes by platform",
`${crashes(logs)}\n| summarize count() by bin_auto(_time), series = strcat(platform, ' ', kind)`,
),
chart(
"TimeSeries",
6,
"handled-over-time",
"Handled errors by platform",
`${handledErrors(logs)}\n| summarize count() by bin_auto(_time), platform`,
),
chart(
"Table",
12,
"top-crashes",
"Top crashes",
`${crashes(logs)}\n| summarize installs = dcount(install), crashes = count(), first_seen = min(_time), last_seen = max(_time), versions = make_set(version, 3) by platform, kind, error_type, message\n| order by installs desc, crashes desc\n| take 15`,
),
chart(
"Table",
12,
"top-handled",
"Top handled errors",
`${handledErrors(logs)}\n| summarize installs = dcount(install), errors = count(), last_seen = max(_time), versions = make_set(version, 3) by platform, error_type, message\n| order by installs desc, errors desc\n| take 15`,
),
chart(
"Table",
6,
"by-version",
"Crashes by app version",
`${crashes(logs)}\n| summarize crashes = count(), installs = dcount(install) by platform, version\n| order by crashes desc\n| take 10`,
),
chart(
"TimeSeries",
6,
"symbolication",
"JS stacks by symbolication outcome",
`['${logs}']\n| extend status = tostring(${field("attributes.relay.symbolication.status", "''")})\n| where isnotempty(status)\n| summarize count() by bin_auto(_time), status`,
),
chart(
"TimeSeries",
12,
"ingest",
"Reports reaching the relay",
`${ingest(traces)}\n| summarize count() by bin_auto(_time), series = strcat(signal, ' ', outcome)`,
),
];
/** Lays charts left to right, wrapping at Axiom's 12-column grid. */
function layout(charts: ReadonlyArray<QueryChart>) {
let x = 0;
let y = 0;
let rowHeight = 0;
return charts.map((chart) => {
const h = chart.type === "Statistic" ? 3 : 6;
if (x + chart.width > 12) {
x = 0;
y += rowHeight;
rowHeight = 0;
}
const cell = { i: chart.id, x, y, w: chart.width, h };
x += chart.width;
rowHeight = Math.max(rowHeight, h);
return cell;
});
}
/**
* Monitors follow the relay's: a deployment opts in by naming its paging notifier. The
* dashboard also comes up on PR previews, so a change to mobile telemetry can be checked
* there; the preview's teardown removes it.
*/
export const MobileMonitoring = Effect.fn("MobileMonitoring")(function* (
logs: Axiom.Dataset,
traces: Axiom.Dataset,
) {
const { stage } = yield* Alchemy.Stack;
const page = yield* notifierConfig("RELAY_ALERT_NOTIFIER_ID");
if (Option.isNone(page) && !isPreviewStage(stage)) return;
const datasets = Output.map(Output.all(logs.name, traces.name), ([logs, traces]) => ({
logs,
traces,
}));
if (Option.isSome(page)) {
const notifiers: Record<Severity, Option.Option<string>> = {
page,
warn: yield* notifierConfig("RELAY_WARNING_NOTIFIER_ID"),
};
for (const monitor of mobileMonitors) {
yield* Axiom.Monitor(monitor.id, {
...monitor.props,
name: stage === "prod" ? monitor.name : `[${stage}] ${monitor.name}`,
description: `${monitor.description} Runbook: ${RUNBOOK_URL}`,
aplQuery: Output.map(datasets, monitor.query),
triggerFromNRuns: monitor.props.triggerFromNRuns ?? 1,
alertOnNoData: monitor.props.alertOnNoData ?? false,
notifierIds: Option.toArray(notifiers[monitor.severity]),
});
}
}
yield* Axiom.Dashboard("MobileHealthDashboard", {
dashboard: Output.map(datasets, (names) => {
const charts = mobileDashboardCharts(names);
return {
name: relayResourceNameForStage("t3-code-mobile-health", stage),
owner: "X-AXIOM-EVERYONE",
description: `T3 Code mobile app crashes and errors. Crashes count JS and native once each. Runbook: ${RUNBOOK_URL}`,
refreshTime: 300,
schemaVersion: 2,
timeWindowStart: "qr-now-7d",
timeWindowEnd: "qr-now",
charts: charts.map(({ width: _width, ...chart }) => chart),
layout: layout(charts),
};
}),
});
});