@growth-labs/monitoring 0.1.0

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.
Files changed (117) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +115 -0
  3. package/SPEC.md +19 -0
  4. package/dist/alerting/dedup.d.ts +6 -0
  5. package/dist/alerting/dedup.d.ts.map +1 -0
  6. package/dist/alerting/dedup.js +49 -0
  7. package/dist/alerting/dedup.js.map +1 -0
  8. package/dist/alerting/escalation.d.ts +4 -0
  9. package/dist/alerting/escalation.d.ts.map +1 -0
  10. package/dist/alerting/escalation.js +26 -0
  11. package/dist/alerting/escalation.js.map +1 -0
  12. package/dist/alerting/index.d.ts +31 -0
  13. package/dist/alerting/index.d.ts.map +1 -0
  14. package/dist/alerting/index.js +50 -0
  15. package/dist/alerting/index.js.map +1 -0
  16. package/dist/alerting/thresholds.d.ts +8 -0
  17. package/dist/alerting/thresholds.d.ts.map +1 -0
  18. package/dist/alerting/thresholds.js +105 -0
  19. package/dist/alerting/thresholds.js.map +1 -0
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +5 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/prober/index.d.ts +19 -0
  25. package/dist/prober/index.d.ts.map +1 -0
  26. package/dist/prober/index.js +48 -0
  27. package/dist/prober/index.js.map +1 -0
  28. package/dist/prober/persist.d.ts +4 -0
  29. package/dist/prober/persist.d.ts.map +1 -0
  30. package/dist/prober/persist.js +21 -0
  31. package/dist/prober/persist.js.map +1 -0
  32. package/dist/prober/runners/get-runner.d.ts +4 -0
  33. package/dist/prober/runners/get-runner.d.ts.map +1 -0
  34. package/dist/prober/runners/get-runner.js +43 -0
  35. package/dist/prober/runners/get-runner.js.map +1 -0
  36. package/dist/prober/runners/happy-path-runner.d.ts +13 -0
  37. package/dist/prober/runners/happy-path-runner.d.ts.map +1 -0
  38. package/dist/prober/runners/happy-path-runner.js +183 -0
  39. package/dist/prober/runners/happy-path-runner.js.map +1 -0
  40. package/dist/prober/runners/post-runner.d.ts +4 -0
  41. package/dist/prober/runners/post-runner.d.ts.map +1 -0
  42. package/dist/prober/runners/post-runner.js +44 -0
  43. package/dist/prober/runners/post-runner.js.map +1 -0
  44. package/dist/prober/surfaces.d.ts +46 -0
  45. package/dist/prober/surfaces.d.ts.map +1 -0
  46. package/dist/prober/surfaces.js +2 -0
  47. package/dist/prober/surfaces.js.map +1 -0
  48. package/dist/schemas/drizzle/schema.d.ts +519 -0
  49. package/dist/schemas/drizzle/schema.d.ts.map +1 -0
  50. package/dist/schemas/drizzle/schema.js +45 -0
  51. package/dist/schemas/drizzle/schema.js.map +1 -0
  52. package/dist/schemas/index.d.ts +2 -0
  53. package/dist/schemas/index.d.ts.map +1 -0
  54. package/dist/schemas/index.js +2 -0
  55. package/dist/schemas/index.js.map +1 -0
  56. package/dist/schemas/migrations/0001_uptime_checks.sql +12 -0
  57. package/dist/schemas/migrations/0002_uptime_incidents.sql +12 -0
  58. package/dist/schemas/migrations/0003_errors.sql +16 -0
  59. package/dist/schemas/migrations/README.md +15 -0
  60. package/dist/status-page/app.d.ts +10 -0
  61. package/dist/status-page/app.d.ts.map +1 -0
  62. package/dist/status-page/app.js +44 -0
  63. package/dist/status-page/app.js.map +1 -0
  64. package/dist/status-page/lib/queries.d.ts +6 -0
  65. package/dist/status-page/lib/queries.d.ts.map +1 -0
  66. package/dist/status-page/lib/queries.js +81 -0
  67. package/dist/status-page/lib/queries.js.map +1 -0
  68. package/dist/status-page/pages/api/status.json.d.ts +3 -0
  69. package/dist/status-page/pages/api/status.json.d.ts.map +1 -0
  70. package/dist/status-page/pages/api/status.json.js +19 -0
  71. package/dist/status-page/pages/api/status.json.js.map +1 -0
  72. package/dist/status-page/shell.d.ts +3 -0
  73. package/dist/status-page/shell.d.ts.map +1 -0
  74. package/dist/status-page/shell.js +18 -0
  75. package/dist/status-page/shell.js.map +1 -0
  76. package/dist/tail/categorize.d.ts +44 -0
  77. package/dist/tail/categorize.d.ts.map +1 -0
  78. package/dist/tail/categorize.js +113 -0
  79. package/dist/tail/categorize.js.map +1 -0
  80. package/dist/tail/fingerprint.d.ts +4 -0
  81. package/dist/tail/fingerprint.d.ts.map +1 -0
  82. package/dist/tail/fingerprint.js +18 -0
  83. package/dist/tail/fingerprint.js.map +1 -0
  84. package/dist/tail/index.d.ts +21 -0
  85. package/dist/tail/index.d.ts.map +1 -0
  86. package/dist/tail/index.js +50 -0
  87. package/dist/tail/index.js.map +1 -0
  88. package/dist/tail/persist.d.ts +15 -0
  89. package/dist/tail/persist.d.ts.map +1 -0
  90. package/dist/tail/persist.js +63 -0
  91. package/dist/tail/persist.js.map +1 -0
  92. package/dist/tail/redact.d.ts +5 -0
  93. package/dist/tail/redact.d.ts.map +1 -0
  94. package/dist/tail/redact.js +25 -0
  95. package/dist/tail/redact.js.map +1 -0
  96. package/dist/tail/sample.d.ts +9 -0
  97. package/dist/tail/sample.d.ts.map +1 -0
  98. package/dist/tail/sample.js +25 -0
  99. package/dist/tail/sample.js.map +1 -0
  100. package/dist/types.d.ts +87 -0
  101. package/dist/types.d.ts.map +1 -0
  102. package/dist/types.js +11 -0
  103. package/dist/types.js.map +1 -0
  104. package/package.json +85 -0
  105. package/src/schemas/migrations/0001_uptime_checks.sql +12 -0
  106. package/src/schemas/migrations/0002_uptime_incidents.sql +12 -0
  107. package/src/schemas/migrations/0003_errors.sql +16 -0
  108. package/src/schemas/migrations/README.md +15 -0
  109. package/src/status-page/README.md +14 -0
  110. package/src/status-page/app.ts +58 -0
  111. package/src/status-page/components/ErrorRollup.astro +26 -0
  112. package/src/status-page/components/IncidentList.astro +25 -0
  113. package/src/status-page/components/SurfaceRow.astro +24 -0
  114. package/src/status-page/lib/queries.ts +114 -0
  115. package/src/status-page/pages/api/status.json.ts +24 -0
  116. package/src/status-page/pages/index.astro +45 -0
  117. package/src/status-page/shell.ts +17 -0
@@ -0,0 +1,9 @@
1
+ import type { SamplingConfig } from '../types.js';
2
+ import type { ErrorCategory } from './categorize.js';
3
+ interface SamplingOptions {
4
+ surface?: string;
5
+ activeIncidentSurfaces?: Set<string>;
6
+ }
7
+ export declare function shouldKeep(category: ErrorCategory, config: SamplingConfig, options?: SamplingOptions): boolean;
8
+ export {};
9
+ //# sourceMappingURL=sample.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sample.d.ts","sourceRoot":"","sources":["../../src/tail/sample.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAEpD,UAAU,eAAe;IACxB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,sBAAsB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CACpC;AAED,wBAAgB,UAAU,CACzB,QAAQ,EAAE,aAAa,EACvB,MAAM,EAAE,cAAc,EACtB,OAAO,GAAE,eAAoB,GAC3B,OAAO,CAMT"}
@@ -0,0 +1,25 @@
1
+ export function shouldKeep(category, config, options = {}) {
2
+ if (options.surface && options.activeIncidentSurfaces?.has(options.surface))
3
+ return true;
4
+ const pct = pctForCategory(category, config);
5
+ if (pct >= 1)
6
+ return true;
7
+ if (pct <= 0)
8
+ return false;
9
+ return Math.random() < pct;
10
+ }
11
+ function pctForCategory(category, config) {
12
+ switch (category) {
13
+ case 'exception':
14
+ return config.exceptionsPct;
15
+ case 'fivexx':
16
+ return config.fivexxPct;
17
+ case 'console-error':
18
+ return config.consoleErrorPct;
19
+ case 'console-warn':
20
+ return config.consoleWarnPct;
21
+ case 'slow-request':
22
+ return config.slowRequestsPct;
23
+ }
24
+ }
25
+ //# sourceMappingURL=sample.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sample.js","sourceRoot":"","sources":["../../src/tail/sample.ts"],"names":[],"mappings":"AAQA,MAAM,UAAU,UAAU,CACzB,QAAuB,EACvB,MAAsB,EACtB,UAA2B,EAAE;IAE7B,IAAI,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,sBAAsB,EAAE,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAA;IACxF,MAAM,GAAG,GAAG,cAAc,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;IAC5C,IAAI,GAAG,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IACzB,IAAI,GAAG,IAAI,CAAC;QAAE,OAAO,KAAK,CAAA;IAC1B,OAAO,IAAI,CAAC,MAAM,EAAE,GAAG,GAAG,CAAA;AAC3B,CAAC;AAED,SAAS,cAAc,CAAC,QAAuB,EAAE,MAAsB;IACtE,QAAQ,QAAQ,EAAE,CAAC;QAClB,KAAK,WAAW;YACf,OAAO,MAAM,CAAC,aAAa,CAAA;QAC5B,KAAK,QAAQ;YACZ,OAAO,MAAM,CAAC,SAAS,CAAA;QACxB,KAAK,eAAe;YACnB,OAAO,MAAM,CAAC,eAAe,CAAA;QAC9B,KAAK,cAAc;YAClB,OAAO,MAAM,CAAC,cAAc,CAAA;QAC7B,KAAK,cAAc;YAClB,OAAO,MAAM,CAAC,eAAe,CAAA;IAC/B,CAAC;AACF,CAAC"}
@@ -0,0 +1,87 @@
1
+ export type CheckType = 'get' | 'post' | 'happy_path';
2
+ export type CheckStatus = 'pass' | 'fail' | 'timeout';
3
+ export type AlertSeverity = 'info' | 'warning' | 'critical';
4
+ export type ErrorSeverity = 'error' | 'warning' | 'exception';
5
+ export interface CheckResult {
6
+ id?: string;
7
+ status: CheckStatus;
8
+ statusCode?: number;
9
+ latencyMs: number;
10
+ errorMessage?: string;
11
+ checkedAt?: number;
12
+ }
13
+ export interface PersistedCheckResult extends CheckResult {
14
+ id: string;
15
+ checkedAt: number;
16
+ }
17
+ export interface NotifyConfig {
18
+ channels: Array<'slack' | 'email'>;
19
+ emailProvider?: 'cloudflare' | 'resend';
20
+ }
21
+ export interface ProbeAlertingConfig {
22
+ consecutiveFailuresToOpen?: number;
23
+ consecutiveSuccessesToClose?: number;
24
+ minSeverity?: 'warning' | 'critical';
25
+ }
26
+ export interface TailAlertingConfig {
27
+ newErrorDedupWindowMs?: number;
28
+ rateSpikeThreshold?: number;
29
+ rateSpikeWindowMs?: number;
30
+ surfaceDownThresholdPct?: number;
31
+ surfaceDownWindowMs?: number;
32
+ }
33
+ export interface ThresholdConfig extends ProbeAlertingConfig, TailAlertingConfig {
34
+ }
35
+ export interface SamplingConfig {
36
+ exceptionsPct: number;
37
+ fivexxPct: number;
38
+ consoleErrorPct: number;
39
+ consoleWarnPct: number;
40
+ slowRequestsPct: number;
41
+ slowRequestThresholdMs: number;
42
+ }
43
+ export interface RuntimeFetch {
44
+ fetcher?: typeof fetch;
45
+ }
46
+ export interface UptimeIncident {
47
+ id: string;
48
+ surface: string;
49
+ opened_at: number;
50
+ closed_at: number | null;
51
+ trigger_check_id: string | null;
52
+ resolve_check_id: string | null;
53
+ severity: 'warning' | 'critical';
54
+ notes: string | null;
55
+ }
56
+ export interface SurfaceStatus {
57
+ name: string;
58
+ status: 'green' | 'yellow' | 'red';
59
+ lastCheckedAt: number | null;
60
+ uptime7d: number;
61
+ }
62
+ export interface ErrorRollup {
63
+ fingerprint: string;
64
+ surface: string;
65
+ message: string;
66
+ count: number;
67
+ lastOccurredAt: number;
68
+ }
69
+ export interface StatusPageSurfaceConfig {
70
+ name: string;
71
+ }
72
+ export interface StatusPageConfig {
73
+ realm: string;
74
+ d1Binding?: string;
75
+ surfaces: StatusPageSurfaceConfig[];
76
+ }
77
+ export interface ScheduledEventLike {
78
+ cron: string;
79
+ }
80
+ export interface ExecutionContextLike {
81
+ waitUntil(promise: Promise<unknown>): void;
82
+ }
83
+ export type IdFactory = () => string;
84
+ export declare function generateId(): string;
85
+ export declare function errorMessage(error: unknown): string;
86
+ export declare function isTimeoutMessage(message: string): boolean;
87
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ export function generateId() {
2
+ return crypto.randomUUID();
3
+ }
4
+ export function errorMessage(error) {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+ export function isTimeoutMessage(message) {
8
+ const lower = message.toLowerCase();
9
+ return lower.includes('timed out') || lower.includes('timeout') || lower.includes('aborted');
10
+ }
11
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAmGA,MAAM,UAAU,UAAU;IACzB,OAAO,MAAM,CAAC,UAAU,EAAE,CAAA;AAC3B,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAc;IAC1C,OAAO,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC9D,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,OAAe;IAC/C,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAA;IACnC,OAAO,KAAK,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAA;AAC7F,CAAC"}
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@growth-labs/monitoring",
3
+ "version": "0.1.0",
4
+ "description": "Operational observability primitives for Growth Labs Cloudflare Workers: synthetic probes, Tail Worker capture, alerting, D1 schemas, and a status page.",
5
+ "type": "module",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./prober": {
13
+ "types": "./dist/prober/index.d.ts",
14
+ "import": "./dist/prober/index.js"
15
+ },
16
+ "./tail": {
17
+ "types": "./dist/tail/index.d.ts",
18
+ "import": "./dist/tail/index.js"
19
+ },
20
+ "./alerting": {
21
+ "types": "./dist/alerting/index.d.ts",
22
+ "import": "./dist/alerting/index.js"
23
+ },
24
+ "./status-page": {
25
+ "types": "./dist/status-page/app.d.ts",
26
+ "import": "./dist/status-page/app.js"
27
+ },
28
+ "./status-page/queries": {
29
+ "types": "./dist/status-page/lib/queries.d.ts",
30
+ "import": "./dist/status-page/lib/queries.js"
31
+ },
32
+ "./status-page/components/*": {
33
+ "types": "./src/status-page/components/*.astro",
34
+ "import": "./src/status-page/components/*.astro"
35
+ },
36
+ "./status-page/pages/*": {
37
+ "types": "./src/status-page/pages/*.astro",
38
+ "import": "./src/status-page/pages/*.astro",
39
+ "default": "./src/status-page/pages/*.astro"
40
+ },
41
+ "./status-page/pages/api/status.json": {
42
+ "types": "./dist/status-page/pages/api/status.json.d.ts",
43
+ "import": "./dist/status-page/pages/api/status.json.js",
44
+ "default": "./dist/status-page/pages/api/status.json.js"
45
+ },
46
+ "./schemas": {
47
+ "types": "./dist/schemas/index.d.ts",
48
+ "import": "./dist/schemas/index.js"
49
+ }
50
+ },
51
+ "files": [
52
+ "dist",
53
+ "src/status-page",
54
+ "src/schemas/migrations",
55
+ "README.md",
56
+ "CHANGELOG.md",
57
+ "SPEC.md"
58
+ ],
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "registry": "https://registry.npmjs.org/"
62
+ },
63
+ "peerDependencies": {
64
+ "astro": "^6.1.10"
65
+ },
66
+ "dependencies": {
67
+ "@growth-labs/analytics": "^0.3.0",
68
+ "@growth-labs/notify": "^0.1.0",
69
+ "drizzle-orm": "^0.45.2",
70
+ "zod": "^3.24.0"
71
+ },
72
+ "devDependencies": {
73
+ "@cloudflare/workers-types": "^4.20260402.1",
74
+ "astro": "^6.1.10",
75
+ "typescript": "^5.7.0",
76
+ "vitest": "^3.0.0"
77
+ },
78
+ "scripts": {
79
+ "build": "tsc && node -e \"require('node:fs').cpSync('src/schemas/migrations','dist/schemas/migrations',{recursive:true})\"",
80
+ "dev": "tsc --watch",
81
+ "test": "vitest run",
82
+ "test:watch": "vitest",
83
+ "check": "biome check src/ __tests__ package.json README.md CHANGELOG.md SPEC.md"
84
+ }
85
+ }
@@ -0,0 +1,12 @@
1
+ CREATE TABLE gl_uptime_checks (
2
+ id TEXT PRIMARY KEY,
3
+ surface TEXT NOT NULL,
4
+ check_type TEXT NOT NULL,
5
+ status TEXT NOT NULL,
6
+ status_code INTEGER,
7
+ latency_ms INTEGER,
8
+ error_message TEXT,
9
+ checked_at INTEGER NOT NULL
10
+ );
11
+ CREATE INDEX idx_uptime_surface_time ON gl_uptime_checks (surface, checked_at DESC);
12
+ CREATE INDEX idx_uptime_status_time ON gl_uptime_checks (status, checked_at DESC);
@@ -0,0 +1,12 @@
1
+ CREATE TABLE gl_uptime_incidents (
2
+ id TEXT PRIMARY KEY,
3
+ surface TEXT NOT NULL,
4
+ opened_at INTEGER NOT NULL,
5
+ closed_at INTEGER,
6
+ trigger_check_id TEXT,
7
+ resolve_check_id TEXT,
8
+ severity TEXT NOT NULL,
9
+ notes TEXT
10
+ );
11
+ CREATE INDEX idx_incidents_surface_open ON gl_uptime_incidents (surface, opened_at DESC);
12
+ CREATE INDEX idx_incidents_open ON gl_uptime_incidents (closed_at) WHERE closed_at IS NULL;
@@ -0,0 +1,16 @@
1
+ CREATE TABLE gl_errors (
2
+ id TEXT PRIMARY KEY,
3
+ realm_key TEXT NOT NULL,
4
+ surface TEXT NOT NULL,
5
+ severity TEXT NOT NULL,
6
+ message TEXT NOT NULL,
7
+ stack TEXT,
8
+ request_id TEXT,
9
+ status_code INTEGER,
10
+ duration_ms INTEGER,
11
+ occurred_at INTEGER NOT NULL,
12
+ fingerprint TEXT NOT NULL
13
+ );
14
+ CREATE INDEX idx_errors_realm_time ON gl_errors (realm_key, occurred_at DESC);
15
+ CREATE INDEX idx_errors_fingerprint_time ON gl_errors (fingerprint, occurred_at DESC);
16
+ CREATE INDEX idx_errors_surface_time ON gl_errors (surface, occurred_at DESC);
@@ -0,0 +1,15 @@
1
+ # @growth-labs/monitoring D1 Migrations
2
+
3
+ Apply these migrations to the consumer's monitoring D1 database before mounting
4
+ the prober, tail worker, or status page.
5
+
6
+ Wrangler example:
7
+
8
+ ```toml
9
+ [[d1_databases]]
10
+ binding = "MONITORING_DB"
11
+ database_name = "monitoring"
12
+ migrations_dir = "node_modules/@growth-labs/monitoring/src/schemas/migrations"
13
+ ```
14
+
15
+ Tables are prefixed with `gl_` per Growth Labs package convention.
@@ -0,0 +1,14 @@
1
+ # Monitoring Status Page
2
+
3
+ `createStatusPageApp(config)` is an Astro integration for a read-only status
4
+ Worker.
5
+
6
+ Defaults:
7
+
8
+ - HTML status route: `/`
9
+ - JSON snapshot route: `/api/status.json`
10
+ - D1 binding: `MONITORING_DB`
11
+
12
+ The page reads current surface status, open incidents, and top 24-hour error
13
+ fingerprints from the monitoring D1 tables. It performs no mutations and has no
14
+ auth dependency.
@@ -0,0 +1,58 @@
1
+ import type { AstroIntegration } from 'astro'
2
+ import type { StatusPageConfig } from '../types.js'
3
+
4
+ export interface StatusPageAppConfig extends StatusPageConfig {
5
+ routePath?: string
6
+ jsonRoutePath?: string
7
+ }
8
+
9
+ export function createStatusPageApp(config: StatusPageAppConfig): AstroIntegration {
10
+ const routePath = config.routePath ?? '/'
11
+ const jsonRoutePath = config.jsonRoutePath ?? '/api/status.json'
12
+ const serializable = {
13
+ realm: config.realm,
14
+ d1Binding: config.d1Binding ?? 'MONITORING_DB',
15
+ surfaces: config.surfaces,
16
+ }
17
+
18
+ return {
19
+ name: '@growth-labs/monitoring/status-page',
20
+ hooks: {
21
+ 'astro:config:setup': ({ injectRoute, updateConfig }) => {
22
+ updateConfig({
23
+ vite: {
24
+ plugins: [
25
+ {
26
+ name: '@growth-labs/monitoring:status-page-config',
27
+ resolveId(id: string) {
28
+ if (id === 'virtual:growth-labs/monitoring/status-page/config') return id
29
+ },
30
+ load(id: string) {
31
+ if (id === 'virtual:growth-labs/monitoring/status-page/config') {
32
+ return `export const config = ${JSON.stringify(serializable)}`
33
+ }
34
+ },
35
+ },
36
+ ],
37
+ },
38
+ })
39
+ injectRoute({
40
+ pattern: routePath,
41
+ entrypoint: '@growth-labs/monitoring/status-page/pages/index',
42
+ })
43
+ injectRoute({
44
+ pattern: jsonRoutePath,
45
+ entrypoint: '@growth-labs/monitoring/status-page/pages/api/status.json',
46
+ })
47
+ },
48
+ },
49
+ }
50
+ }
51
+
52
+ export type { ErrorRollup, StatusPageConfig, SurfaceStatus, UptimeIncident } from '../types.js'
53
+ export {
54
+ getCurrentSurfaceStatuses,
55
+ getOpenIncidents,
56
+ getSurfaceUptime,
57
+ getTopErrors,
58
+ } from './lib/queries.js'
@@ -0,0 +1,26 @@
1
+ ---
2
+ import type { ErrorRollup as ErrorRollupItem } from '../../types.js'
3
+
4
+ interface Props {
5
+ errors: ErrorRollupItem[]
6
+ }
7
+
8
+ const { errors } = Astro.props
9
+ ---
10
+
11
+ <section class="grid content-start gap-3">
12
+ <h2 class="font-semibold text-lg">Top errors</h2>
13
+ {errors.length === 0 ? (
14
+ <p class="text-sm text-zinc-400">No recent errors</p>
15
+ ) : (
16
+ <ul class="grid gap-2">
17
+ {errors.map((error) => (
18
+ <li class="border-zinc-800 border-b py-2">
19
+ <p class="font-medium text-sm">{error.surface}</p>
20
+ <p class="truncate text-sm text-zinc-400">{error.message}</p>
21
+ <p class="text-xs text-zinc-500">{error.count} · {error.fingerprint}</p>
22
+ </li>
23
+ ))}
24
+ </ul>
25
+ )}
26
+ </section>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import type { UptimeIncident } from '../../types.js'
3
+
4
+ interface Props {
5
+ incidents: UptimeIncident[]
6
+ }
7
+
8
+ const { incidents } = Astro.props
9
+ ---
10
+
11
+ <section class="grid content-start gap-3">
12
+ <h2 class="font-semibold text-lg">Recent incidents</h2>
13
+ {incidents.length === 0 ? (
14
+ <p class="text-sm text-zinc-400">No open incidents</p>
15
+ ) : (
16
+ <ul class="grid gap-2">
17
+ {incidents.map((incident) => (
18
+ <li class="border-zinc-800 border-b py-2">
19
+ <p class="font-medium text-sm">{incident.surface}</p>
20
+ <p class="text-sm text-zinc-400">{incident.severity} · opened {new Date(incident.opened_at * 1000).toUTCString()}</p>
21
+ </li>
22
+ ))}
23
+ </ul>
24
+ )}
25
+ </section>
@@ -0,0 +1,24 @@
1
+ ---
2
+ import type { SurfaceStatus } from '../../types.js'
3
+ import { formatUptime, relativeTime } from '../shell.js'
4
+
5
+ interface Props {
6
+ surface: SurfaceStatus
7
+ }
8
+
9
+ const { surface } = Astro.props
10
+ const pillClass = {
11
+ green: 'bg-emerald-400/15 text-emerald-200 ring-emerald-400/30',
12
+ yellow: 'bg-amber-400/15 text-amber-200 ring-amber-400/30',
13
+ red: 'bg-red-400/15 text-red-200 ring-red-400/30',
14
+ }[surface.status]
15
+ ---
16
+
17
+ <article class="grid grid-cols-[auto_1fr_auto] items-center gap-4 border-zinc-800 border-b py-3" data-surface={surface.name}>
18
+ <span class:list={['rounded px-2 py-1 text-xs ring-1', pillClass]}>{surface.status}</span>
19
+ <h2 class="truncate font-medium text-base">{surface.name}</h2>
20
+ <div class="text-right text-sm text-zinc-400">
21
+ <p>{relativeTime(surface.lastCheckedAt)}</p>
22
+ <p>{formatUptime(surface.uptime7d)} 7d</p>
23
+ </div>
24
+ </article>
@@ -0,0 +1,114 @@
1
+ import type {
2
+ ErrorRollup,
3
+ StatusPageSurfaceConfig,
4
+ SurfaceStatus,
5
+ UptimeIncident,
6
+ } from '../../types.js'
7
+
8
+ interface CheckRow {
9
+ id: string
10
+ surface: string
11
+ status: string
12
+ checked_at: number
13
+ }
14
+
15
+ interface ErrorRollupRow {
16
+ fingerprint: string
17
+ surface: string
18
+ message: string
19
+ count: number
20
+ occurred_at: number
21
+ }
22
+
23
+ export async function getCurrentSurfaceStatuses(
24
+ db: D1Database,
25
+ surfaces: StatusPageSurfaceConfig[],
26
+ ): Promise<SurfaceStatus[]> {
27
+ const openIncidents = await getOpenIncidents(db)
28
+ return Promise.all(
29
+ surfaces.map(async (surface) => {
30
+ const lastCheck = await db
31
+ .prepare(`
32
+ SELECT id, surface, status, checked_at
33
+ FROM gl_uptime_checks
34
+ WHERE surface = ?
35
+ ORDER BY checked_at DESC
36
+ LIMIT 1
37
+ `)
38
+ .bind(surface.name)
39
+ .first<CheckRow>()
40
+ const incident = openIncidents.find((row) => row.surface === surface.name)
41
+ const uptime7d = await getSurfaceUptime(db, surface.name, 7)
42
+ return {
43
+ name: surface.name,
44
+ status: statusFor(lastCheck ?? null, incident),
45
+ lastCheckedAt: lastCheck?.checked_at ?? null,
46
+ uptime7d,
47
+ }
48
+ }),
49
+ )
50
+ }
51
+
52
+ export async function getOpenIncidents(db: D1Database): Promise<UptimeIncident[]> {
53
+ const { results } = await db
54
+ .prepare(`
55
+ SELECT id, surface, opened_at, closed_at, trigger_check_id, resolve_check_id, severity, notes
56
+ FROM gl_uptime_incidents
57
+ WHERE closed_at IS NULL
58
+ ORDER BY opened_at DESC
59
+ `)
60
+ .all<UptimeIncident>()
61
+ return results ?? []
62
+ }
63
+
64
+ export async function getTopErrors(db: D1Database, hoursBack: number): Promise<ErrorRollup[]> {
65
+ const since = Math.floor(Date.now() / 1000) - hoursBack * 60 * 60
66
+ const { results } = await db
67
+ .prepare(`
68
+ SELECT fingerprint, surface, message, COUNT(*) AS count, MAX(occurred_at) AS occurred_at
69
+ FROM gl_errors
70
+ WHERE occurred_at >= ?
71
+ GROUP BY fingerprint
72
+ ORDER BY count DESC, occurred_at DESC
73
+ LIMIT 5
74
+ `)
75
+ .bind(since)
76
+ .all<ErrorRollupRow>()
77
+ return (results ?? []).map((row) => ({
78
+ fingerprint: row.fingerprint,
79
+ surface: row.surface,
80
+ message: row.message,
81
+ count: Number(row.count),
82
+ lastOccurredAt: Number(row.occurred_at),
83
+ }))
84
+ }
85
+
86
+ export async function getSurfaceUptime(
87
+ db: D1Database,
88
+ surface: string,
89
+ daysBack: number,
90
+ ): Promise<number> {
91
+ const since = Math.floor(Date.now() / 1000) - daysBack * 24 * 60 * 60
92
+ const { results } = await db
93
+ .prepare(`
94
+ SELECT id, surface, status, checked_at
95
+ FROM gl_uptime_checks
96
+ WHERE surface = ? AND checked_at >= ?
97
+ ORDER BY checked_at DESC
98
+ `)
99
+ .bind(surface, since)
100
+ .all<CheckRow>()
101
+ const rows = results ?? []
102
+ if (rows.length === 0) return 1
103
+ return rows.filter((row) => row.status === 'pass').length / rows.length
104
+ }
105
+
106
+ function statusFor(
107
+ check: CheckRow | null,
108
+ incident: UptimeIncident | undefined,
109
+ ): SurfaceStatus['status'] {
110
+ if (incident?.severity === 'critical') return 'red'
111
+ if (incident?.severity === 'warning') return 'yellow'
112
+ if (!check) return 'yellow'
113
+ return check.status === 'pass' ? 'green' : 'yellow'
114
+ }
@@ -0,0 +1,24 @@
1
+ import { config } from 'virtual:growth-labs/monitoring/status-page/config'
2
+ import type { APIRoute } from 'astro'
3
+ import { getCurrentSurfaceStatuses, getOpenIncidents, getTopErrors } from '../../lib/queries.js'
4
+
5
+ export const GET: APIRoute = async ({ locals }) => {
6
+ const env = (locals as { runtime?: { env?: Record<string, unknown> } }).runtime?.env ?? {}
7
+ const db = env[config.d1Binding] as D1Database
8
+ const [surfaces, openIncidents, topErrors] = await Promise.all([
9
+ getCurrentSurfaceStatuses(db, config.surfaces),
10
+ getOpenIncidents(db),
11
+ getTopErrors(db, 24),
12
+ ])
13
+
14
+ return new Response(
15
+ JSON.stringify({
16
+ realm: config.realm,
17
+ generatedAt: Math.floor(Date.now() / 1000),
18
+ surfaces,
19
+ openIncidents,
20
+ topErrors,
21
+ }),
22
+ { headers: { 'Content-Type': 'application/json' } },
23
+ )
24
+ }
@@ -0,0 +1,45 @@
1
+ ---
2
+ import { config } from 'virtual:growth-labs/monitoring/status-page/config'
3
+ import IncidentList from '../components/IncidentList.astro'
4
+ import SurfaceRow from '../components/SurfaceRow.astro'
5
+ import ErrorRollup from '../components/ErrorRollup.astro'
6
+ import { getCurrentSurfaceStatuses, getOpenIncidents, getTopErrors } from '../lib/queries.js'
7
+
8
+ const env = Astro.locals.runtime?.env ?? {}
9
+ const db = env[config.d1Binding] as D1Database
10
+ const [surfaces, incidents, topErrors] = await Promise.all([
11
+ getCurrentSurfaceStatuses(db, config.surfaces),
12
+ getOpenIncidents(db),
13
+ getTopErrors(db, 24),
14
+ ])
15
+ const generatedAt = Math.floor(Date.now() / 1000)
16
+ ---
17
+
18
+ <!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="utf-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
23
+ <title>{config.realm} status</title>
24
+ </head>
25
+ <body class="min-h-screen bg-zinc-950 text-zinc-100">
26
+ <main class="mx-auto flex max-w-4xl flex-col gap-8 px-4 py-8">
27
+ <header class="flex flex-wrap items-end justify-between gap-4 border-zinc-800 border-b pb-4">
28
+ <div>
29
+ <p class="text-sm text-zinc-400">Status</p>
30
+ <h1 class="font-semibold text-3xl tracking-normal">{config.realm}</h1>
31
+ </div>
32
+ <p class="text-sm text-zinc-400">Updated <time datetime={new Date(generatedAt * 1000).toISOString()}>{new Date(generatedAt * 1000).toUTCString()}</time></p>
33
+ </header>
34
+
35
+ <section class="grid gap-2" data-monitoring-status-surfaces>
36
+ {surfaces.map((surface) => <SurfaceRow surface={surface} />)}
37
+ </section>
38
+
39
+ <section class="grid gap-4 md:grid-cols-2">
40
+ <IncidentList incidents={incidents.slice(0, 5)} />
41
+ <ErrorRollup errors={topErrors.slice(0, 5)} />
42
+ </section>
43
+ </main>
44
+ </body>
45
+ </html>
@@ -0,0 +1,17 @@
1
+ export function relativeTime(
2
+ timestampSeconds: number | null,
3
+ nowSeconds = Math.floor(Date.now() / 1000),
4
+ ): string {
5
+ if (!timestampSeconds) return 'never'
6
+ const diff = Math.max(0, nowSeconds - timestampSeconds)
7
+ if (diff < 60) return `${diff}s ago`
8
+ const minutes = Math.floor(diff / 60)
9
+ if (minutes < 60) return `${minutes}m ago`
10
+ const hours = Math.floor(minutes / 60)
11
+ if (hours < 24) return `${hours}h ago`
12
+ return `${Math.floor(hours / 24)}d ago`
13
+ }
14
+
15
+ export function formatUptime(value: number): string {
16
+ return `${(value * 100).toFixed(2)}%`
17
+ }