@growth-labs/ops-digest 0.1.0 → 0.1.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/CHANGELOG.md +8 -0
- package/package.json +6 -1
- package/src/anomaly.ts +58 -0
- package/src/engine.ts +204 -0
- package/src/handlers/common.ts +86 -0
- package/src/handlers/composite-ratio.ts +26 -0
- package/src/handlers/d1-row-count-by-event.ts +29 -0
- package/src/handlers/d1-row-count.ts +21 -0
- package/src/handlers/index.ts +72 -0
- package/src/handlers/wae-avg-double.ts +29 -0
- package/src/handlers/wae-event-count-by-blob.ts +38 -0
- package/src/handlers/wae-event-count.ts +22 -0
- package/src/handlers/wae-top-n-by-blob.ts +40 -0
- package/src/handlers/wae-unique-count.ts +29 -0
- package/src/idempotency.ts +40 -0
- package/src/index.ts +58 -0
- package/src/render.ts +83 -0
- package/src/schema.ts +114 -0
- package/src/types.ts +175 -0
- package/src/window.ts +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @growth-labs/ops-digest Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1 — 2026-05-21
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Include package source files in the published npm tarball so shipped `.js.map`
|
|
8
|
+
and `.d.ts.map` entries resolve without Vite/Astro sourcemap warnings in
|
|
9
|
+
tarball-installed consumers.
|
|
10
|
+
|
|
3
11
|
## 0.1.0 - 2026-05-18
|
|
4
12
|
|
|
5
13
|
- Initial release.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/ops-digest",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Worker-runtime digest engine for D1/WAE operational metrics, anomaly callouts, notify delivery, and KV idempotency.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
|
+
"src/**/*.ts",
|
|
16
|
+
"src/**/*.astro",
|
|
17
|
+
"src/**/*.css",
|
|
18
|
+
"src/**/*.md",
|
|
19
|
+
"src/**/*.sql",
|
|
15
20
|
"README.md",
|
|
16
21
|
"SPEC.md",
|
|
17
22
|
"CHANGELOG.md"
|
package/src/anomaly.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AnomalyConfig, AnomalyHit } from './types.js'
|
|
2
|
+
|
|
3
|
+
export function detectAnomaly(
|
|
4
|
+
current: number,
|
|
5
|
+
history: number[],
|
|
6
|
+
config: AnomalyConfig,
|
|
7
|
+
metricName = '',
|
|
8
|
+
): AnomalyHit | null {
|
|
9
|
+
const override = metricName ? config.overrides?.[metricName] : undefined
|
|
10
|
+
const effective = { ...config, ...override }
|
|
11
|
+
|
|
12
|
+
if (effective.method === 'none') return null
|
|
13
|
+
if (history.length < 14) return null
|
|
14
|
+
|
|
15
|
+
const sorted = [...history].sort((a, b) => a - b)
|
|
16
|
+
const medianValue = median(sorted)
|
|
17
|
+
if (medianValue < effective.medianFloor) return null
|
|
18
|
+
|
|
19
|
+
const q1 = median(sorted.slice(0, Math.floor(sorted.length / 2)))
|
|
20
|
+
const q3 = median(sorted.slice(Math.ceil(sorted.length / 2)))
|
|
21
|
+
const iqr = q3 - q1
|
|
22
|
+
const multiplier = effective.iqrMultiplier ?? 1.5
|
|
23
|
+
const lowerBand = medianValue - multiplier * iqr
|
|
24
|
+
const upperBand = medianValue + multiplier * iqr
|
|
25
|
+
|
|
26
|
+
if (current < lowerBand) {
|
|
27
|
+
return {
|
|
28
|
+
metric: metricName,
|
|
29
|
+
current,
|
|
30
|
+
median: medianValue,
|
|
31
|
+
iqr,
|
|
32
|
+
lowerBand,
|
|
33
|
+
upperBand,
|
|
34
|
+
direction: 'below',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (current > upperBand) {
|
|
39
|
+
return {
|
|
40
|
+
metric: metricName,
|
|
41
|
+
current,
|
|
42
|
+
median: medianValue,
|
|
43
|
+
iqr,
|
|
44
|
+
lowerBand,
|
|
45
|
+
upperBand,
|
|
46
|
+
direction: 'above',
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function median(sorted: number[]): number {
|
|
54
|
+
if (sorted.length === 0) return 0
|
|
55
|
+
const middle = Math.floor(sorted.length / 2)
|
|
56
|
+
if (sorted.length % 2 === 1) return sorted[middle]
|
|
57
|
+
return (sorted[middle - 1] + sorted[middle]) / 2
|
|
58
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { notify } from '@growth-labs/notify'
|
|
2
|
+
import { detectAnomaly } from './anomaly.js'
|
|
3
|
+
import { metricValueToNumber } from './handlers/common.js'
|
|
4
|
+
import { computeMetric } from './handlers/index.js'
|
|
5
|
+
import {
|
|
6
|
+
checkIdempotency,
|
|
7
|
+
formatIdempotencyKey,
|
|
8
|
+
hashRenderedDigest,
|
|
9
|
+
writeIdempotencyMarker,
|
|
10
|
+
} from './idempotency.js'
|
|
11
|
+
import { renderDigest } from './render.js'
|
|
12
|
+
import type {
|
|
13
|
+
DigestDefinition,
|
|
14
|
+
DigestRunResult,
|
|
15
|
+
HandlerContext,
|
|
16
|
+
MetricDefinition,
|
|
17
|
+
MetricResult,
|
|
18
|
+
MetricValue,
|
|
19
|
+
OpsDigestConfig,
|
|
20
|
+
OpsDigestEnv,
|
|
21
|
+
PercentChange,
|
|
22
|
+
ResolvedDigestWindow,
|
|
23
|
+
WaeSourceConfig,
|
|
24
|
+
} from './types.js'
|
|
25
|
+
import { resolveDigestWindow, shiftWindowDays, splitTrailingDays } from './window.js'
|
|
26
|
+
|
|
27
|
+
export async function runDigest(
|
|
28
|
+
definition: DigestDefinition,
|
|
29
|
+
config: OpsDigestConfig,
|
|
30
|
+
env: OpsDigestEnv,
|
|
31
|
+
_ctx: ExecutionContext,
|
|
32
|
+
): Promise<DigestRunResult> {
|
|
33
|
+
const window = resolveDigestWindow(definition.window)
|
|
34
|
+
const idempotencyKey = formatIdempotencyKey(definition.name, window.startMs)
|
|
35
|
+
const errors: string[] = []
|
|
36
|
+
const context: HandlerContext = {
|
|
37
|
+
d1: resolveBinding<D1Database>(env, config.d1Binding),
|
|
38
|
+
sources: buildSources(config),
|
|
39
|
+
env,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const metrics: MetricResult[] = []
|
|
43
|
+
for (const metric of definition.metrics) {
|
|
44
|
+
const result = await computeMetricResult(metric, definition, window, context, errors)
|
|
45
|
+
if (result) metrics.push(result)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rendered = renderDigest(definition.render, metrics, definition.brandLabel, window)
|
|
49
|
+
const renderedHash = await hashRenderedDigest(rendered)
|
|
50
|
+
const kv = resolveBinding<KVNamespace>(env, config.idempotencyKv)
|
|
51
|
+
const state = await checkIdempotency(kv, definition.name, window.startMs, renderedHash)
|
|
52
|
+
|
|
53
|
+
if (state === 'duplicate') {
|
|
54
|
+
return buildResult(definition.name, window, rendered, idempotencyKey, false, metrics, errors)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (state === 'changed') {
|
|
58
|
+
console.warn('ops-digest: idempotency marker changed; skipping rerun', {
|
|
59
|
+
key: idempotencyKey,
|
|
60
|
+
})
|
|
61
|
+
return buildResult(definition.name, window, rendered, idempotencyKey, false, metrics, errors)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const severity = metrics.some((metric) => metric.anomaly) ? 'warning' : 'info'
|
|
65
|
+
const notifyResult = await notify(env, {
|
|
66
|
+
...config.notifyConfig,
|
|
67
|
+
channels: definition.channels,
|
|
68
|
+
severity,
|
|
69
|
+
title: `${definition.brandLabel} Ops Digest`,
|
|
70
|
+
body: rendered,
|
|
71
|
+
dedupKey: idempotencyKey,
|
|
72
|
+
})
|
|
73
|
+
const posted = notifyResult.sent.length > 0
|
|
74
|
+
if (posted) {
|
|
75
|
+
await writeIdempotencyMarker(kv, definition.name, window.startMs, renderedHash)
|
|
76
|
+
} else {
|
|
77
|
+
errors.push(`notify: no channels sent (${notifyResult.failed.map((f) => f.error).join('; ')})`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return buildResult(definition.name, window, rendered, idempotencyKey, posted, metrics, errors)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function computeMetricResult(
|
|
84
|
+
metric: MetricDefinition,
|
|
85
|
+
definition: DigestDefinition,
|
|
86
|
+
window: ResolvedDigestWindow,
|
|
87
|
+
context: HandlerContext,
|
|
88
|
+
errors: string[],
|
|
89
|
+
): Promise<MetricResult | null> {
|
|
90
|
+
try {
|
|
91
|
+
const current = await computeMetric(metric, window, context)
|
|
92
|
+
const currentNumber = metricValueToNumber(current)
|
|
93
|
+
const baseline = await computeBaseline(metric, window, context, errors)
|
|
94
|
+
const history = await computeHistory(metric, window, context, errors)
|
|
95
|
+
const delta = formatPercentChange(currentNumber, baseline)
|
|
96
|
+
const anomaly =
|
|
97
|
+
detectAnomaly(currentNumber, history, definition.anomaly, metric.name) ?? undefined
|
|
98
|
+
if (anomaly) anomaly.metric = metric.name
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
name: metric.name,
|
|
102
|
+
current,
|
|
103
|
+
baseline,
|
|
104
|
+
delta,
|
|
105
|
+
anomaly,
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
109
|
+
errors.push(`${metric.name}: ${message}`)
|
|
110
|
+
console.error('ops-digest: metric failed', { metric: metric.name, err })
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function computeBaseline(
|
|
116
|
+
metric: MetricDefinition,
|
|
117
|
+
window: ResolvedDigestWindow,
|
|
118
|
+
context: HandlerContext,
|
|
119
|
+
errors: string[],
|
|
120
|
+
): Promise<number> {
|
|
121
|
+
const values: number[] = []
|
|
122
|
+
for (let offset = 1; offset <= 7; offset += 1) {
|
|
123
|
+
try {
|
|
124
|
+
values.push(
|
|
125
|
+
metricValueToNumber(await computeMetric(metric, shiftWindowDays(window, offset), context)),
|
|
126
|
+
)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
129
|
+
errors.push(`${metric.name} baseline-${offset}: ${message}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (values.length === 0) return 0
|
|
133
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function computeHistory(
|
|
137
|
+
metric: MetricDefinition,
|
|
138
|
+
window: ResolvedDigestWindow,
|
|
139
|
+
context: HandlerContext,
|
|
140
|
+
errors: string[],
|
|
141
|
+
): Promise<number[]> {
|
|
142
|
+
const values: number[] = []
|
|
143
|
+
for (const [index, historyWindow] of splitTrailingDays(window, 30).entries()) {
|
|
144
|
+
try {
|
|
145
|
+
values.push(metricValueToNumber(await computeMetric(metric, historyWindow, context)))
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
148
|
+
errors.push(`${metric.name} history-${index + 1}: ${message}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return values
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildResult(
|
|
155
|
+
digestName: string,
|
|
156
|
+
window: ResolvedDigestWindow,
|
|
157
|
+
rendered: string,
|
|
158
|
+
idempotencyKey: string,
|
|
159
|
+
posted: boolean,
|
|
160
|
+
metrics: MetricResult[],
|
|
161
|
+
errors: string[],
|
|
162
|
+
): DigestRunResult {
|
|
163
|
+
return {
|
|
164
|
+
digestName,
|
|
165
|
+
windowStart: window.startMs,
|
|
166
|
+
windowEnd: window.endMs,
|
|
167
|
+
rendered,
|
|
168
|
+
idempotencyKey,
|
|
169
|
+
posted,
|
|
170
|
+
metrics,
|
|
171
|
+
errors,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildSources(config: OpsDigestConfig): Record<string, WaeSourceConfig> {
|
|
176
|
+
return {
|
|
177
|
+
...(config.authWae ? { authWae: config.authWae } : {}),
|
|
178
|
+
...(config.contentWae ? { contentWae: config.contentWae } : {}),
|
|
179
|
+
...(config.extraWae ?? {}),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveBinding<T>(env: OpsDigestEnv, name: string): T {
|
|
184
|
+
const binding = env[name]
|
|
185
|
+
if (!binding) throw new Error(`ops-digest: env binding "${name}" is missing`)
|
|
186
|
+
return binding as T
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function formatPercentChange(current: number, previous: number): PercentChange {
|
|
190
|
+
if (previous === 0) {
|
|
191
|
+
if (current === 0) return { value: '0%', direction: 'flat' }
|
|
192
|
+
return { value: '—', direction: current > 0 ? 'up' : 'down' }
|
|
193
|
+
}
|
|
194
|
+
const pct = ((current - previous) / previous) * 100
|
|
195
|
+
if (pct === 0) return { value: '0%', direction: 'flat' }
|
|
196
|
+
return {
|
|
197
|
+
value: `${Math.abs(pct).toFixed(1)}%`,
|
|
198
|
+
direction: pct > 0 ? 'up' : 'down',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function metricValueForEngine(value: MetricValue): number {
|
|
203
|
+
return metricValueToNumber(value)
|
|
204
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { queryWAE } from '@growth-labs/analytics/utils/wae-query'
|
|
2
|
+
import { assertValidWaeDataset } from '@growth-labs/analytics/utils/wae-validate'
|
|
3
|
+
import type {
|
|
4
|
+
HandlerContext,
|
|
5
|
+
MetricValue,
|
|
6
|
+
ResolvedDigestWindow,
|
|
7
|
+
WaeSourceConfig,
|
|
8
|
+
} from '../types.js'
|
|
9
|
+
import { waeWindowSql } from '../window.js'
|
|
10
|
+
|
|
11
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
12
|
+
|
|
13
|
+
export function quoteIdentifier(identifier: string): string {
|
|
14
|
+
assertSqlIdentifier(identifier)
|
|
15
|
+
return `"${identifier}"`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assertSqlIdentifier(identifier: string): void {
|
|
19
|
+
if (!IDENTIFIER_PATTERN.test(identifier)) {
|
|
20
|
+
throw new Error(`ops-digest: invalid SQL identifier "${identifier}"`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function numericResult(row: Record<string, unknown> | null | undefined): number {
|
|
25
|
+
const value = row?.c
|
|
26
|
+
if (typeof value === 'number') return value
|
|
27
|
+
if (typeof value === 'string') return Number(value)
|
|
28
|
+
return 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function metricValueToNumber(value: MetricValue): number {
|
|
32
|
+
if (typeof value === 'number') return value
|
|
33
|
+
if (Array.isArray(value)) return value.reduce((sum, item) => sum + item.count, 0)
|
|
34
|
+
return Object.values(value).reduce((sum, count) => sum + count, 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveD1(context: HandlerContext): D1Database {
|
|
38
|
+
if (!context.d1) throw new Error('ops-digest: D1 binding is missing')
|
|
39
|
+
return context.d1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function d1TimeBounds(
|
|
43
|
+
window: ResolvedDigestWindow,
|
|
44
|
+
unit: 's' | 'ms' | undefined,
|
|
45
|
+
): [number, number] {
|
|
46
|
+
if (unit === 'ms') return [window.startMs, window.endMs]
|
|
47
|
+
return [Math.floor(window.startMs / 1000), Math.floor(window.endMs / 1000)]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveWaeSource(context: HandlerContext, sourceName: string): WaeSourceConfig {
|
|
51
|
+
const source = context.sources?.[sourceName]
|
|
52
|
+
if (!source) throw new Error(`ops-digest: WAE source "${sourceName}" is not configured`)
|
|
53
|
+
return source
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function queryWaeRows<T extends Record<string, unknown>>(
|
|
57
|
+
source: WaeSourceConfig,
|
|
58
|
+
context: HandlerContext,
|
|
59
|
+
sql: string,
|
|
60
|
+
): Promise<T[]> {
|
|
61
|
+
assertValidWaeDataset(source.dataset, source.realmFilter ?? source.siteFilter ?? '')
|
|
62
|
+
const token = context.env?.[source.apiTokenSecret]
|
|
63
|
+
if (typeof token !== 'string' || token.trim() === '') {
|
|
64
|
+
throw new Error(`ops-digest: WAE token secret "${source.apiTokenSecret}" is missing`)
|
|
65
|
+
}
|
|
66
|
+
const fetchFn = context.env?.fetch ?? fetch
|
|
67
|
+
const result = await queryWAE(source.accountId, source.dataset, sql, token, fetchFn)
|
|
68
|
+
return result.data as T[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function waeConditions(
|
|
72
|
+
source: WaeSourceConfig,
|
|
73
|
+
window: ResolvedDigestWindow,
|
|
74
|
+
eventName?: string,
|
|
75
|
+
): string[] {
|
|
76
|
+
const conditions: string[] = []
|
|
77
|
+
if (eventName) conditions.push(`blob1 = ${sqlString(eventName)}`)
|
|
78
|
+
const filter = source.realmFilter ?? source.siteFilter
|
|
79
|
+
if (filter) conditions.push(`blob2 = ${sqlString(filter)}`)
|
|
80
|
+
conditions.push(waeWindowSql(window))
|
|
81
|
+
return conditions
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function sqlString(value: string): string {
|
|
85
|
+
return `'${value.replaceAll("'", "''")}'`
|
|
86
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HandlerContext, MetricDefinition, ResolvedDigestWindow } from '../types.js'
|
|
2
|
+
import { metricValueToNumber } from './common.js'
|
|
3
|
+
|
|
4
|
+
type CompositeRatioMetric = Extract<MetricDefinition, { handler: 'composite.ratio' }>
|
|
5
|
+
|
|
6
|
+
export async function computeCompositeRatio(
|
|
7
|
+
metric: CompositeRatioMetric,
|
|
8
|
+
window: ResolvedDigestWindow,
|
|
9
|
+
context: HandlerContext,
|
|
10
|
+
compute: (
|
|
11
|
+
metric: MetricDefinition,
|
|
12
|
+
window: ResolvedDigestWindow,
|
|
13
|
+
context: HandlerContext,
|
|
14
|
+
) => Promise<unknown>,
|
|
15
|
+
): Promise<number> {
|
|
16
|
+
const numerator = metricValueToNumber(
|
|
17
|
+
(await compute(metric.numerator, window, context)) as Parameters<typeof metricValueToNumber>[0],
|
|
18
|
+
)
|
|
19
|
+
const denominator = metricValueToNumber(
|
|
20
|
+
(await compute(metric.denominator, window, context)) as Parameters<
|
|
21
|
+
typeof metricValueToNumber
|
|
22
|
+
>[0],
|
|
23
|
+
)
|
|
24
|
+
if (denominator === 0) return 0
|
|
25
|
+
return numerator / denominator
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HandlerContext, MetricDefinition, ResolvedDigestWindow } from '../types.js'
|
|
2
|
+
import { d1TimeBounds, numericResult, quoteIdentifier, resolveD1 } from './common.js'
|
|
3
|
+
|
|
4
|
+
type D1RowCountByEventMetric = Extract<MetricDefinition, { handler: 'd1.row-count-by-event' }>
|
|
5
|
+
|
|
6
|
+
export async function computeD1RowCountByEvent(
|
|
7
|
+
metric: D1RowCountByEventMetric,
|
|
8
|
+
window: ResolvedDigestWindow,
|
|
9
|
+
context: HandlerContext,
|
|
10
|
+
): Promise<number> {
|
|
11
|
+
if (metric.eventNames.length === 0) return 0
|
|
12
|
+
const d1 = resolveD1(context)
|
|
13
|
+
const eventColumn = metric.eventColumn ?? 'event_name'
|
|
14
|
+
const timeColumn = metric.timeColumn ?? 'processed_at'
|
|
15
|
+
const placeholders = metric.eventNames.map(() => '?').join(', ')
|
|
16
|
+
const [start, end] = d1TimeBounds(window, metric.timeColumnUnit)
|
|
17
|
+
const row = await d1
|
|
18
|
+
.prepare(
|
|
19
|
+
[
|
|
20
|
+
`SELECT COUNT(*) AS c FROM ${quoteIdentifier(metric.table)}`,
|
|
21
|
+
`WHERE ${quoteIdentifier(eventColumn)} IN (${placeholders})`,
|
|
22
|
+
`AND ${quoteIdentifier(timeColumn)} >= ?`,
|
|
23
|
+
`AND ${quoteIdentifier(timeColumn)} < ?`,
|
|
24
|
+
].join(' '),
|
|
25
|
+
)
|
|
26
|
+
.bind(...metric.eventNames, start, end)
|
|
27
|
+
.first<Record<string, unknown>>()
|
|
28
|
+
return numericResult(row)
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HandlerContext, MetricDefinition, ResolvedDigestWindow } from '../types.js'
|
|
2
|
+
import { d1TimeBounds, numericResult, quoteIdentifier, resolveD1 } from './common.js'
|
|
3
|
+
|
|
4
|
+
type D1RowCountMetric = Extract<MetricDefinition, { handler: 'd1.row-count' }>
|
|
5
|
+
|
|
6
|
+
export async function computeD1RowCount(
|
|
7
|
+
metric: D1RowCountMetric,
|
|
8
|
+
window: ResolvedDigestWindow,
|
|
9
|
+
context: HandlerContext,
|
|
10
|
+
): Promise<number> {
|
|
11
|
+
const d1 = resolveD1(context)
|
|
12
|
+
const timeColumn = metric.timeColumn ?? 'created_at'
|
|
13
|
+
const [start, end] = d1TimeBounds(window, metric.timeColumnUnit)
|
|
14
|
+
const row = await d1
|
|
15
|
+
.prepare(
|
|
16
|
+
`SELECT COUNT(*) AS c FROM ${quoteIdentifier(metric.table)} WHERE ${quoteIdentifier(timeColumn)} >= ? AND ${quoteIdentifier(timeColumn)} < ?`,
|
|
17
|
+
)
|
|
18
|
+
.bind(start, end)
|
|
19
|
+
.first<Record<string, unknown>>()
|
|
20
|
+
return numericResult(row)
|
|
21
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HandlerContext,
|
|
3
|
+
MetricDefinition,
|
|
4
|
+
MetricValue,
|
|
5
|
+
ResolvedDigestWindow,
|
|
6
|
+
} from '../types.js'
|
|
7
|
+
import { computeCompositeRatio } from './composite-ratio.js'
|
|
8
|
+
import { computeD1RowCount } from './d1-row-count.js'
|
|
9
|
+
import { computeD1RowCountByEvent } from './d1-row-count-by-event.js'
|
|
10
|
+
import { computeWaeAvgDouble } from './wae-avg-double.js'
|
|
11
|
+
import { computeWaeEventCount } from './wae-event-count.js'
|
|
12
|
+
import { computeWaeEventCountByBlob } from './wae-event-count-by-blob.js'
|
|
13
|
+
import { computeWaeTopNByBlob } from './wae-top-n-by-blob.js'
|
|
14
|
+
import { computeWaeUniqueCount } from './wae-unique-count.js'
|
|
15
|
+
|
|
16
|
+
export const handlerRegistry = {
|
|
17
|
+
'd1.row-count': {
|
|
18
|
+
description: 'Counts D1 rows in the digest window using a created/processed timestamp column.',
|
|
19
|
+
},
|
|
20
|
+
'd1.row-count-by-event': {
|
|
21
|
+
description: 'Counts D1 rows whose event column matches one of the configured event names.',
|
|
22
|
+
},
|
|
23
|
+
'wae.event-count': {
|
|
24
|
+
description: 'Counts WAE rows for one event name and optional realm/site filter.',
|
|
25
|
+
},
|
|
26
|
+
'wae.event-count-by-blob': {
|
|
27
|
+
description: 'Counts one WAE event grouped by a blob field.',
|
|
28
|
+
},
|
|
29
|
+
'wae.unique-count': {
|
|
30
|
+
description: 'Counts distinct values in a WAE blob field.',
|
|
31
|
+
},
|
|
32
|
+
'wae.avg-double': {
|
|
33
|
+
description: 'Averages a WAE double field.',
|
|
34
|
+
},
|
|
35
|
+
'wae.top-n-by-blob': {
|
|
36
|
+
description: 'Returns the top N WAE blob values by event count.',
|
|
37
|
+
},
|
|
38
|
+
'composite.ratio': {
|
|
39
|
+
description: 'Computes numerator / denominator using nested metric definitions.',
|
|
40
|
+
},
|
|
41
|
+
} as const
|
|
42
|
+
|
|
43
|
+
export async function computeMetric(
|
|
44
|
+
metric: MetricDefinition,
|
|
45
|
+
window: ResolvedDigestWindow,
|
|
46
|
+
context: HandlerContext,
|
|
47
|
+
): Promise<MetricValue> {
|
|
48
|
+
switch (metric.handler) {
|
|
49
|
+
case 'd1.row-count':
|
|
50
|
+
return computeD1RowCount(metric, window, context)
|
|
51
|
+
case 'd1.row-count-by-event':
|
|
52
|
+
return computeD1RowCountByEvent(metric, window, context)
|
|
53
|
+
case 'wae.event-count':
|
|
54
|
+
return computeWaeEventCount(metric, window, context)
|
|
55
|
+
case 'wae.event-count-by-blob':
|
|
56
|
+
return computeWaeEventCountByBlob(metric, window, context)
|
|
57
|
+
case 'wae.unique-count':
|
|
58
|
+
return computeWaeUniqueCount(metric, window, context)
|
|
59
|
+
case 'wae.avg-double':
|
|
60
|
+
return computeWaeAvgDouble(metric, window, context)
|
|
61
|
+
case 'wae.top-n-by-blob':
|
|
62
|
+
return computeWaeTopNByBlob(metric, window, context)
|
|
63
|
+
case 'composite.ratio':
|
|
64
|
+
return computeCompositeRatio(metric, window, context, computeMetric)
|
|
65
|
+
default:
|
|
66
|
+
return assertNever(metric)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertNever(value: never): never {
|
|
71
|
+
throw new Error(`ops-digest: unsupported metric handler "${String(value)}"`)
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HandlerContext, MetricDefinition, ResolvedDigestWindow } from '../types.js'
|
|
2
|
+
import {
|
|
3
|
+
assertSqlIdentifier,
|
|
4
|
+
numericResult,
|
|
5
|
+
queryWaeRows,
|
|
6
|
+
resolveWaeSource,
|
|
7
|
+
waeConditions,
|
|
8
|
+
} from './common.js'
|
|
9
|
+
|
|
10
|
+
type WaeAvgDoubleMetric = Extract<MetricDefinition, { handler: 'wae.avg-double' }>
|
|
11
|
+
|
|
12
|
+
export async function computeWaeAvgDouble(
|
|
13
|
+
metric: WaeAvgDoubleMetric,
|
|
14
|
+
window: ResolvedDigestWindow,
|
|
15
|
+
context: HandlerContext,
|
|
16
|
+
): Promise<number> {
|
|
17
|
+
assertSqlIdentifier(metric.doubleField)
|
|
18
|
+
const source = resolveWaeSource(context, metric.source)
|
|
19
|
+
const rows = await queryWaeRows(
|
|
20
|
+
source,
|
|
21
|
+
context,
|
|
22
|
+
[
|
|
23
|
+
`SELECT avg(${metric.doubleField}) AS c`,
|
|
24
|
+
`FROM ${source.dataset}`,
|
|
25
|
+
`WHERE ${waeConditions(source, window, metric.eventName).join(' AND ')}`,
|
|
26
|
+
].join(' '),
|
|
27
|
+
)
|
|
28
|
+
return numericResult(rows[0])
|
|
29
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GroupedMetricResult,
|
|
3
|
+
HandlerContext,
|
|
4
|
+
MetricDefinition,
|
|
5
|
+
ResolvedDigestWindow,
|
|
6
|
+
} from '../types.js'
|
|
7
|
+
import {
|
|
8
|
+
assertSqlIdentifier,
|
|
9
|
+
numericResult,
|
|
10
|
+
queryWaeRows,
|
|
11
|
+
resolveWaeSource,
|
|
12
|
+
waeConditions,
|
|
13
|
+
} from './common.js'
|
|
14
|
+
|
|
15
|
+
type WaeEventCountByBlobMetric = Extract<MetricDefinition, { handler: 'wae.event-count-by-blob' }>
|
|
16
|
+
|
|
17
|
+
export async function computeWaeEventCountByBlob(
|
|
18
|
+
metric: WaeEventCountByBlobMetric,
|
|
19
|
+
window: ResolvedDigestWindow,
|
|
20
|
+
context: HandlerContext,
|
|
21
|
+
): Promise<GroupedMetricResult> {
|
|
22
|
+
assertSqlIdentifier(metric.groupBy)
|
|
23
|
+
const source = resolveWaeSource(context, metric.source)
|
|
24
|
+
const rows = await queryWaeRows<{ group: unknown; c: unknown }>(
|
|
25
|
+
source,
|
|
26
|
+
context,
|
|
27
|
+
[
|
|
28
|
+
`SELECT ${metric.groupBy} AS group, count() AS c`,
|
|
29
|
+
`FROM ${source.dataset}`,
|
|
30
|
+
`WHERE ${waeConditions(source, window, metric.eventName).join(' AND ')}`,
|
|
31
|
+
`GROUP BY ${metric.groupBy}`,
|
|
32
|
+
'ORDER BY c DESC',
|
|
33
|
+
].join(' '),
|
|
34
|
+
)
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
rows.map((row) => [String(row.group ?? ''), numericResult(row as Record<string, unknown>)]),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HandlerContext, MetricDefinition, ResolvedDigestWindow } from '../types.js'
|
|
2
|
+
import { numericResult, queryWaeRows, resolveWaeSource, waeConditions } from './common.js'
|
|
3
|
+
|
|
4
|
+
type WaeEventCountMetric = Extract<MetricDefinition, { handler: 'wae.event-count' }>
|
|
5
|
+
|
|
6
|
+
export async function computeWaeEventCount(
|
|
7
|
+
metric: WaeEventCountMetric,
|
|
8
|
+
window: ResolvedDigestWindow,
|
|
9
|
+
context: HandlerContext,
|
|
10
|
+
): Promise<number> {
|
|
11
|
+
const source = resolveWaeSource(context, metric.source)
|
|
12
|
+
const rows = await queryWaeRows(
|
|
13
|
+
source,
|
|
14
|
+
context,
|
|
15
|
+
[
|
|
16
|
+
'SELECT count() AS c',
|
|
17
|
+
`FROM ${source.dataset}`,
|
|
18
|
+
`WHERE ${waeConditions(source, window, metric.eventName).join(' AND ')}`,
|
|
19
|
+
].join(' '),
|
|
20
|
+
)
|
|
21
|
+
return numericResult(rows[0])
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HandlerContext,
|
|
3
|
+
MetricDefinition,
|
|
4
|
+
ResolvedDigestWindow,
|
|
5
|
+
TopNResult,
|
|
6
|
+
} from '../types.js'
|
|
7
|
+
import {
|
|
8
|
+
assertSqlIdentifier,
|
|
9
|
+
numericResult,
|
|
10
|
+
queryWaeRows,
|
|
11
|
+
resolveWaeSource,
|
|
12
|
+
waeConditions,
|
|
13
|
+
} from './common.js'
|
|
14
|
+
|
|
15
|
+
type WaeTopNByBlobMetric = Extract<MetricDefinition, { handler: 'wae.top-n-by-blob' }>
|
|
16
|
+
|
|
17
|
+
export async function computeWaeTopNByBlob(
|
|
18
|
+
metric: WaeTopNByBlobMetric,
|
|
19
|
+
window: ResolvedDigestWindow,
|
|
20
|
+
context: HandlerContext,
|
|
21
|
+
): Promise<TopNResult> {
|
|
22
|
+
assertSqlIdentifier(metric.groupBy)
|
|
23
|
+
const source = resolveWaeSource(context, metric.source)
|
|
24
|
+
const rows = await queryWaeRows<{ group: unknown; c: unknown }>(
|
|
25
|
+
source,
|
|
26
|
+
context,
|
|
27
|
+
[
|
|
28
|
+
`SELECT ${metric.groupBy} AS group, count() AS c`,
|
|
29
|
+
`FROM ${source.dataset}`,
|
|
30
|
+
`WHERE ${waeConditions(source, window, metric.eventName).join(' AND ')}`,
|
|
31
|
+
`GROUP BY ${metric.groupBy}`,
|
|
32
|
+
'ORDER BY c DESC',
|
|
33
|
+
`LIMIT ${metric.limit}`,
|
|
34
|
+
].join(' '),
|
|
35
|
+
)
|
|
36
|
+
return rows.map((row) => ({
|
|
37
|
+
group: String(row.group ?? ''),
|
|
38
|
+
count: numericResult(row as Record<string, unknown>),
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HandlerContext, MetricDefinition, ResolvedDigestWindow } from '../types.js'
|
|
2
|
+
import {
|
|
3
|
+
assertSqlIdentifier,
|
|
4
|
+
numericResult,
|
|
5
|
+
queryWaeRows,
|
|
6
|
+
resolveWaeSource,
|
|
7
|
+
waeConditions,
|
|
8
|
+
} from './common.js'
|
|
9
|
+
|
|
10
|
+
type WaeUniqueCountMetric = Extract<MetricDefinition, { handler: 'wae.unique-count' }>
|
|
11
|
+
|
|
12
|
+
export async function computeWaeUniqueCount(
|
|
13
|
+
metric: WaeUniqueCountMetric,
|
|
14
|
+
window: ResolvedDigestWindow,
|
|
15
|
+
context: HandlerContext,
|
|
16
|
+
): Promise<number> {
|
|
17
|
+
assertSqlIdentifier(metric.uniqueBlob)
|
|
18
|
+
const source = resolveWaeSource(context, metric.source)
|
|
19
|
+
const rows = await queryWaeRows(
|
|
20
|
+
source,
|
|
21
|
+
context,
|
|
22
|
+
[
|
|
23
|
+
`SELECT count(distinct ${metric.uniqueBlob}) AS c`,
|
|
24
|
+
`FROM ${source.dataset}`,
|
|
25
|
+
`WHERE ${waeConditions(source, window, metric.eventName).join(' AND ')}`,
|
|
26
|
+
].join(' '),
|
|
27
|
+
)
|
|
28
|
+
return numericResult(rows[0])
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const TTL_SECONDS = 7 * 24 * 60 * 60
|
|
2
|
+
|
|
3
|
+
export type IdempotencyState = 'first-run' | 'duplicate' | 'changed'
|
|
4
|
+
|
|
5
|
+
export async function checkIdempotency(
|
|
6
|
+
kv: KVNamespace,
|
|
7
|
+
digestName: string,
|
|
8
|
+
windowStart: number,
|
|
9
|
+
computedHash: string,
|
|
10
|
+
): Promise<IdempotencyState> {
|
|
11
|
+
const key = formatIdempotencyKey(digestName, windowStart)
|
|
12
|
+
const existing = await kv.get(key)
|
|
13
|
+
if (existing === null) return 'first-run'
|
|
14
|
+
return existing === computedHash ? 'duplicate' : 'changed'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeIdempotencyMarker(
|
|
18
|
+
kv: KVNamespace,
|
|
19
|
+
digestName: string,
|
|
20
|
+
windowStart: number,
|
|
21
|
+
hash: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
await kv.put(formatIdempotencyKey(digestName, windowStart), hash, {
|
|
24
|
+
expirationTtl: TTL_SECONDS,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatIdempotencyKey(digestName: string, windowStart: number): string {
|
|
29
|
+
return `digest:${digestName}:${formatYYYYMMDD(windowStart)}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function hashRenderedDigest(rendered: string): Promise<string> {
|
|
33
|
+
const encoded = new TextEncoder().encode(rendered)
|
|
34
|
+
const digest = await crypto.subtle.digest('SHA-256', encoded)
|
|
35
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatYYYYMMDD(ms: number): string {
|
|
39
|
+
return new Date(ms).toISOString().slice(0, 10)
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { runDigest as runDigestDefinition } from './engine.js'
|
|
2
|
+
import { opsDigestConfigSchema } from './schema.js'
|
|
3
|
+
import type { DigestRunResult, OpsDigestConfig, OpsDigestEnv, OpsDigestHandle } from './types.js'
|
|
4
|
+
|
|
5
|
+
export function opsDigest(config: OpsDigestConfig): OpsDigestHandle {
|
|
6
|
+
const parsed = opsDigestConfigSchema.parse(config) as OpsDigestConfig
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
async scheduledHandler(
|
|
10
|
+
event: ScheduledEvent,
|
|
11
|
+
env: OpsDigestEnv,
|
|
12
|
+
ctx: ExecutionContext,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
const cron = event.cron
|
|
15
|
+
const matchingDigest = parsed.digests.find((digest) => digest.schedule === cron)
|
|
16
|
+
if (!matchingDigest) {
|
|
17
|
+
console.warn('ops-digest: scheduled event with no matching digest', { cron })
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await runDigestDefinition(matchingDigest, parsed, env, ctx)
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error('ops-digest: scheduled digest failed', { digest: matchingDigest.name, err })
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async runDigest(
|
|
29
|
+
digestName: string,
|
|
30
|
+
env: OpsDigestEnv,
|
|
31
|
+
ctx: ExecutionContext,
|
|
32
|
+
): Promise<DigestRunResult> {
|
|
33
|
+
const definition = parsed.digests.find((digest) => digest.name === digestName)
|
|
34
|
+
if (!definition) throw new Error(`ops-digest: digest "${digestName}" is not configured`)
|
|
35
|
+
return runDigestDefinition(definition, parsed, env, ctx)
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { handlerRegistry } from './handlers/index.js'
|
|
41
|
+
export type {
|
|
42
|
+
AnomalyConfig,
|
|
43
|
+
AnomalyHit,
|
|
44
|
+
DigestDefinition,
|
|
45
|
+
DigestRunResult,
|
|
46
|
+
GroupedMetricResult,
|
|
47
|
+
MetricDefinition,
|
|
48
|
+
MetricResult,
|
|
49
|
+
MetricValue,
|
|
50
|
+
NotifyConfig,
|
|
51
|
+
OpsDigestConfig,
|
|
52
|
+
OpsDigestEnv,
|
|
53
|
+
OpsDigestHandle,
|
|
54
|
+
PercentChange,
|
|
55
|
+
ResolvedDigestWindow,
|
|
56
|
+
TopNResult,
|
|
57
|
+
WaeSourceConfig,
|
|
58
|
+
} from './types.js'
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MetricResult, MetricValue, ResolvedDigestWindow } from './types.js'
|
|
2
|
+
|
|
3
|
+
export function renderDigest(
|
|
4
|
+
template: string,
|
|
5
|
+
metrics: MetricResult[],
|
|
6
|
+
brandLabel: string,
|
|
7
|
+
window: ResolvedDigestWindow,
|
|
8
|
+
): string {
|
|
9
|
+
if (template !== 'standard') {
|
|
10
|
+
throw new Error(`ops-digest: unsupported render template "${template}"`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lines = [`*${brandLabel} - Daily Ops Digest · ${formatDate(window.startMs)}*`, '']
|
|
14
|
+
|
|
15
|
+
for (const metric of metrics) {
|
|
16
|
+
const currentTotal = metricValueTotal(metric.current)
|
|
17
|
+
lines.push(
|
|
18
|
+
`• ${metricLabel(metric.name)} ${formatNumber(currentTotal)} (7d avg ${formatNumber(metric.baseline)} · ${formatDelta(metric.delta)})`,
|
|
19
|
+
)
|
|
20
|
+
for (const nested of nestedMetricLines(metric.current)) {
|
|
21
|
+
lines.push(` - ${nested}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
lines.push('')
|
|
26
|
+
const anomalies = metrics.filter((metric) => metric.anomaly)
|
|
27
|
+
if (anomalies.length === 0) {
|
|
28
|
+
lines.push('_No anomalies._')
|
|
29
|
+
} else {
|
|
30
|
+
for (const metric of anomalies) {
|
|
31
|
+
const anomaly = metric.anomaly
|
|
32
|
+
if (!anomaly) continue
|
|
33
|
+
lines.push(
|
|
34
|
+
`⚠️ ${metricLabel(metric.name)} ${anomaly.direction} ${formatDelta(metric.delta)} vs 7d avg (${formatNumber(anomaly.current)} vs ${formatNumber(anomaly.median)} typical) - investigate`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return lines.join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function metricValueTotal(value: MetricValue): number {
|
|
43
|
+
if (typeof value === 'number') return value
|
|
44
|
+
if (Array.isArray(value)) return value.reduce((sum, item) => sum + item.count, 0)
|
|
45
|
+
return Object.values(value).reduce((sum, item) => sum + item, 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nestedMetricLines(value: MetricValue): string[] {
|
|
49
|
+
if (typeof value === 'number') return []
|
|
50
|
+
if (Array.isArray(value)) return value.map((item) => `${item.group}: ${formatNumber(item.count)}`)
|
|
51
|
+
return Object.entries(value).map(([group, count]) => `${group}: ${formatNumber(count)}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function metricLabel(name: string): string {
|
|
55
|
+
const base = name.endsWith('-by-provider') ? name.slice(0, -'-by-provider'.length) : name
|
|
56
|
+
const special: Record<string, string> = {
|
|
57
|
+
signins: 'Sign-ins',
|
|
58
|
+
'write-for-us': 'Write-for-us',
|
|
59
|
+
}
|
|
60
|
+
if (special[base]) return special[base]
|
|
61
|
+
const words = base.split('-')
|
|
62
|
+
return words.map((word, index) => (index === 0 ? capitalize(word) : word)).join(' ')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function capitalize(word: string): string {
|
|
66
|
+
if (word.length === 0) return word
|
|
67
|
+
return `${word[0].toUpperCase()}${word.slice(1)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatDelta(delta: MetricResult['delta']): string {
|
|
71
|
+
if (delta.direction === 'up') return `+${delta.value}`
|
|
72
|
+
if (delta.direction === 'down') return `-${delta.value}`
|
|
73
|
+
return delta.value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatNumber(value: number): string {
|
|
77
|
+
if (Number.isInteger(value)) return String(value)
|
|
78
|
+
return value.toFixed(2).replace(/\.?0+$/, '')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatDate(ms: number): string {
|
|
82
|
+
return new Date(ms).toISOString().slice(0, 10)
|
|
83
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { OpsDigestConfig } from './types.js'
|
|
3
|
+
|
|
4
|
+
const waeSourceSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
accountId: z.string().min(1),
|
|
7
|
+
dataset: z.string().min(1),
|
|
8
|
+
apiTokenSecret: z.string().min(1),
|
|
9
|
+
realmFilter: z.string().min(1).optional(),
|
|
10
|
+
siteFilter: z.string().min(1).optional(),
|
|
11
|
+
})
|
|
12
|
+
.refine((source) => !(source.realmFilter && source.siteFilter), {
|
|
13
|
+
message: 'Configure either realmFilter or siteFilter, not both',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const windowSpecSchema = z.union([
|
|
17
|
+
z.literal('previous-day-utc'),
|
|
18
|
+
z.literal('previous-week-utc'),
|
|
19
|
+
z.literal('previous-month-utc'),
|
|
20
|
+
z.object({ days: z.number().int().positive(), anchorTo: z.literal('utc-midnight') }),
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
const d1RowCountSchema = z.object({
|
|
24
|
+
handler: z.literal('d1.row-count'),
|
|
25
|
+
name: z.string().min(1),
|
|
26
|
+
table: z.string().min(1),
|
|
27
|
+
timeColumn: z.string().min(1).optional(),
|
|
28
|
+
timeColumnUnit: z.enum(['s', 'ms']).optional(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const d1RowCountByEventSchema = d1RowCountSchema.extend({
|
|
32
|
+
handler: z.literal('d1.row-count-by-event'),
|
|
33
|
+
eventNames: z.array(z.string().min(1)).min(1),
|
|
34
|
+
eventColumn: z.string().min(1).optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
type MetricSchema = z.ZodType<OpsDigestConfig['digests'][number]['metrics'][number]>
|
|
38
|
+
|
|
39
|
+
const metricSchema: MetricSchema = z.lazy(() =>
|
|
40
|
+
z.discriminatedUnion('handler', [
|
|
41
|
+
d1RowCountSchema,
|
|
42
|
+
d1RowCountByEventSchema,
|
|
43
|
+
z.object({
|
|
44
|
+
handler: z.literal('wae.event-count'),
|
|
45
|
+
name: z.string().min(1),
|
|
46
|
+
source: z.string().min(1),
|
|
47
|
+
eventName: z.string().min(1),
|
|
48
|
+
}),
|
|
49
|
+
z.object({
|
|
50
|
+
handler: z.literal('wae.event-count-by-blob'),
|
|
51
|
+
name: z.string().min(1),
|
|
52
|
+
source: z.string().min(1),
|
|
53
|
+
eventName: z.string().min(1),
|
|
54
|
+
groupBy: z.string().min(1),
|
|
55
|
+
}),
|
|
56
|
+
z.object({
|
|
57
|
+
handler: z.literal('wae.unique-count'),
|
|
58
|
+
name: z.string().min(1),
|
|
59
|
+
source: z.string().min(1),
|
|
60
|
+
eventName: z.string().min(1).optional(),
|
|
61
|
+
uniqueBlob: z.string().min(1),
|
|
62
|
+
}),
|
|
63
|
+
z.object({
|
|
64
|
+
handler: z.literal('wae.avg-double'),
|
|
65
|
+
name: z.string().min(1),
|
|
66
|
+
source: z.string().min(1),
|
|
67
|
+
eventName: z.string().min(1).optional(),
|
|
68
|
+
doubleField: z.string().min(1),
|
|
69
|
+
}),
|
|
70
|
+
z.object({
|
|
71
|
+
handler: z.literal('wae.top-n-by-blob'),
|
|
72
|
+
name: z.string().min(1),
|
|
73
|
+
source: z.string().min(1),
|
|
74
|
+
eventName: z.string().min(1).optional(),
|
|
75
|
+
groupBy: z.string().min(1),
|
|
76
|
+
limit: z.number().int().positive(),
|
|
77
|
+
}),
|
|
78
|
+
z.object({
|
|
79
|
+
handler: z.literal('composite.ratio'),
|
|
80
|
+
name: z.string().min(1),
|
|
81
|
+
numerator: metricSchema,
|
|
82
|
+
denominator: metricSchema,
|
|
83
|
+
}),
|
|
84
|
+
]),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const anomalySchema = z.object({
|
|
88
|
+
method: z.enum(['iqr', 'none']),
|
|
89
|
+
medianFloor: z.number().min(0),
|
|
90
|
+
iqrMultiplier: z.number().positive().optional(),
|
|
91
|
+
overrides: z.record(z.string(), z.object({}).passthrough()).optional(),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const digestSchema = z.object({
|
|
95
|
+
name: z.string().min(1),
|
|
96
|
+
brandLabel: z.string().min(1),
|
|
97
|
+
schedule: z.string().min(1),
|
|
98
|
+
window: windowSpecSchema,
|
|
99
|
+
metrics: z.array(metricSchema),
|
|
100
|
+
anomaly: anomalySchema,
|
|
101
|
+
render: z.string().min(1),
|
|
102
|
+
channels: z.array(z.enum(['slack', 'email'])).min(1),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export const opsDigestConfigSchema = z.object({
|
|
106
|
+
realmId: z.string().min(1),
|
|
107
|
+
d1Binding: z.string().min(1),
|
|
108
|
+
authWae: waeSourceSchema.optional(),
|
|
109
|
+
contentWae: waeSourceSchema.optional(),
|
|
110
|
+
extraWae: z.record(z.string(), waeSourceSchema).optional(),
|
|
111
|
+
idempotencyKv: z.string().min(1),
|
|
112
|
+
notifyConfig: z.object({}).passthrough(),
|
|
113
|
+
digests: z.array(digestSchema).min(1),
|
|
114
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { WindowSpec } from '@growth-labs/analytics/utils/time-windows'
|
|
2
|
+
import type { Channel, EmailProvider, NotifyEnv, NotifyInput, Severity } from '@growth-labs/notify'
|
|
3
|
+
|
|
4
|
+
export type { Channel, Severity, WindowSpec }
|
|
5
|
+
|
|
6
|
+
export type NotifyConfig = Partial<
|
|
7
|
+
Pick<NotifyInput, 'blocks' | 'dedupFn' | 'onSent' | 'emailProvider'>
|
|
8
|
+
> & {
|
|
9
|
+
channels?: Channel[]
|
|
10
|
+
severity?: Severity
|
|
11
|
+
emailProvider?: EmailProvider
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface OpsDigestConfig {
|
|
15
|
+
realmId: string
|
|
16
|
+
d1Binding: string
|
|
17
|
+
authWae?: WaeSourceConfig
|
|
18
|
+
contentWae?: WaeSourceConfig
|
|
19
|
+
extraWae?: Record<string, WaeSourceConfig>
|
|
20
|
+
idempotencyKv: string
|
|
21
|
+
notifyConfig: NotifyConfig
|
|
22
|
+
digests: DigestDefinition[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WaeSourceConfig {
|
|
26
|
+
accountId: string
|
|
27
|
+
dataset: string
|
|
28
|
+
apiTokenSecret: string
|
|
29
|
+
realmFilter?: string
|
|
30
|
+
siteFilter?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DigestDefinition {
|
|
34
|
+
name: string
|
|
35
|
+
brandLabel: string
|
|
36
|
+
schedule: string
|
|
37
|
+
window: WindowSpec
|
|
38
|
+
metrics: MetricDefinition[]
|
|
39
|
+
anomaly: AnomalyConfig
|
|
40
|
+
render: 'standard' | string
|
|
41
|
+
channels: Channel[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface D1MetricBase {
|
|
45
|
+
name: string
|
|
46
|
+
table: string
|
|
47
|
+
timeColumn?: string
|
|
48
|
+
timeColumnUnit?: 's' | 'ms'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type MetricDefinition =
|
|
52
|
+
| ({ handler: 'd1.row-count' } & D1MetricBase)
|
|
53
|
+
| ({
|
|
54
|
+
handler: 'd1.row-count-by-event'
|
|
55
|
+
eventNames: string[]
|
|
56
|
+
eventColumn?: string
|
|
57
|
+
} & D1MetricBase)
|
|
58
|
+
| { handler: 'wae.event-count'; name: string; source: string; eventName: string }
|
|
59
|
+
| {
|
|
60
|
+
handler: 'wae.event-count-by-blob'
|
|
61
|
+
name: string
|
|
62
|
+
source: string
|
|
63
|
+
eventName: string
|
|
64
|
+
groupBy: string
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
handler: 'wae.unique-count'
|
|
68
|
+
name: string
|
|
69
|
+
source: string
|
|
70
|
+
eventName?: string
|
|
71
|
+
uniqueBlob: string
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
handler: 'wae.avg-double'
|
|
75
|
+
name: string
|
|
76
|
+
source: string
|
|
77
|
+
eventName?: string
|
|
78
|
+
doubleField: string
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
handler: 'wae.top-n-by-blob'
|
|
82
|
+
name: string
|
|
83
|
+
source: string
|
|
84
|
+
eventName?: string
|
|
85
|
+
groupBy: string
|
|
86
|
+
limit: number
|
|
87
|
+
}
|
|
88
|
+
| {
|
|
89
|
+
handler: 'composite.ratio'
|
|
90
|
+
name: string
|
|
91
|
+
numerator: MetricDefinition
|
|
92
|
+
denominator: MetricDefinition
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AnomalyConfig {
|
|
96
|
+
method: 'iqr' | 'none'
|
|
97
|
+
medianFloor: number
|
|
98
|
+
iqrMultiplier?: number
|
|
99
|
+
overrides?: Record<string, Partial<AnomalyConfig>>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface AnomalyHit {
|
|
103
|
+
metric: string
|
|
104
|
+
current: number
|
|
105
|
+
median: number
|
|
106
|
+
iqr: number
|
|
107
|
+
lowerBand: number
|
|
108
|
+
upperBand: number
|
|
109
|
+
direction: 'above' | 'below'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface OpsDigestHandle {
|
|
113
|
+
scheduledHandler: (
|
|
114
|
+
event: ScheduledEvent,
|
|
115
|
+
env: OpsDigestEnv,
|
|
116
|
+
ctx: ExecutionContext,
|
|
117
|
+
) => Promise<void>
|
|
118
|
+
runDigest: (
|
|
119
|
+
digestName: string,
|
|
120
|
+
env: OpsDigestEnv,
|
|
121
|
+
ctx: ExecutionContext,
|
|
122
|
+
) => Promise<DigestRunResult>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface DigestRunResult {
|
|
126
|
+
digestName: string
|
|
127
|
+
windowStart: number
|
|
128
|
+
windowEnd: number
|
|
129
|
+
rendered: string
|
|
130
|
+
idempotencyKey: string
|
|
131
|
+
posted: boolean
|
|
132
|
+
metrics: MetricResult[]
|
|
133
|
+
errors: string[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface PercentChange {
|
|
137
|
+
value: string
|
|
138
|
+
direction: 'up' | 'down' | 'flat'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type GroupedMetricResult = Record<string, number>
|
|
142
|
+
export type TopNResult = { group: string; count: number }[]
|
|
143
|
+
export type MetricValue = number | GroupedMetricResult | TopNResult
|
|
144
|
+
|
|
145
|
+
export interface MetricResult {
|
|
146
|
+
name: string
|
|
147
|
+
current: MetricValue
|
|
148
|
+
baseline: number
|
|
149
|
+
delta: PercentChange
|
|
150
|
+
anomaly?: AnomalyHit
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ResolvedDigestWindow {
|
|
154
|
+
startMs: number
|
|
155
|
+
endMs: number
|
|
156
|
+
sqlStart: string
|
|
157
|
+
sqlEnd: string
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface HandlerContext {
|
|
161
|
+
d1?: D1Database
|
|
162
|
+
sources?: Record<string, WaeSourceConfig>
|
|
163
|
+
env?: OpsDigestEnv
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type MetricHandler<T extends MetricDefinition = MetricDefinition> = (
|
|
167
|
+
metric: T,
|
|
168
|
+
window: ResolvedDigestWindow,
|
|
169
|
+
context: HandlerContext,
|
|
170
|
+
) => Promise<MetricValue>
|
|
171
|
+
|
|
172
|
+
export interface OpsDigestEnv extends NotifyEnv {
|
|
173
|
+
fetch?: typeof fetch
|
|
174
|
+
[key: string]: unknown
|
|
175
|
+
}
|
package/src/window.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolveWindow, type WindowSpec } from '@growth-labs/analytics/utils/time-windows'
|
|
2
|
+
import type { ResolvedDigestWindow } from './types.js'
|
|
3
|
+
|
|
4
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
5
|
+
|
|
6
|
+
export function resolveDigestWindow(
|
|
7
|
+
spec: WindowSpec,
|
|
8
|
+
now: number = Date.now(),
|
|
9
|
+
): ResolvedDigestWindow {
|
|
10
|
+
return resolveWindow(spec, now)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function shiftWindowDays(
|
|
14
|
+
window: ResolvedDigestWindow,
|
|
15
|
+
daysBack: number,
|
|
16
|
+
): ResolvedDigestWindow {
|
|
17
|
+
const delta = daysBack * DAY_MS
|
|
18
|
+
return literalWindow(window.startMs - delta, window.endMs - delta)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function splitTrailingDays(
|
|
22
|
+
window: ResolvedDigestWindow,
|
|
23
|
+
days: number,
|
|
24
|
+
): ResolvedDigestWindow[] {
|
|
25
|
+
return Array.from({ length: days }, (_, index) => shiftWindowDays(window, index + 1))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function waeWindowSql(window: ResolvedDigestWindow, timestampColumn = 'timestamp'): string {
|
|
29
|
+
return `${timestampColumn} >= ${window.sqlStart} AND ${timestampColumn} < ${window.sqlEnd}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function literalWindow(startMs: number, endMs: number): ResolvedDigestWindow {
|
|
33
|
+
return {
|
|
34
|
+
startMs,
|
|
35
|
+
endMs,
|
|
36
|
+
sqlStart: `toDateTime('${formatSqlDateTime(startMs)}')`,
|
|
37
|
+
sqlEnd: `toDateTime('${formatSqlDateTime(endMs)}')`,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatSqlDateTime(ms: number): string {
|
|
42
|
+
const iso = new Date(ms).toISOString()
|
|
43
|
+
return iso.slice(0, 19).replace('T', ' ')
|
|
44
|
+
}
|