@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 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.0",
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
+ }