@zeyolabs/kit 0.0.0-stage → 2026.11.6
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/README.md +102 -2
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +77 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +124 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +101 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +264 -0
- package/auth/users.ts +49 -0
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +51 -0
- package/cloudflare/email/dev.ts +14 -0
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +18 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +156 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +176 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { create, toBinary } from '@bufbuild/protobuf';
|
|
2
|
+
import * as snappy from 'snappyjs';
|
|
3
|
+
|
|
4
|
+
// Direct push into Prometheus over a VPC-style binding (Workers VPC, no public host). No ambient
|
|
5
|
+
// context, no app-specific knowledge — the binding + credentials are passed in.
|
|
6
|
+
import { waitUntil } from '../../cloudflare/wait-until';
|
|
7
|
+
import { WriteRequestSchema } from './gen/remote_write_pb';
|
|
8
|
+
|
|
9
|
+
export type PrometheusSample = {
|
|
10
|
+
labels?: Record<string, string>;
|
|
11
|
+
name: string;
|
|
12
|
+
timestampMs?: number;
|
|
13
|
+
value: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Just the shape callers depend on — a test can hand-write a fake object matching this
|
|
17
|
+
* (`{ push: vi.fn(), pushAndForget: vi.fn(), pushWithRetry: vi.fn() }`) instead of constructing
|
|
18
|
+
* `PrometheusClient` with a fake `vpc.fetch`. */
|
|
19
|
+
export interface IPrometheusClient {
|
|
20
|
+
push(...samples: PrometheusSample[]): Promise<void>;
|
|
21
|
+
pushAndForget(...samples: PrometheusSample[]): void;
|
|
22
|
+
pushWithRetry(attempts?: number, ...samples: PrometheusSample[]): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PrometheusConfig = {
|
|
26
|
+
password: string;
|
|
27
|
+
user: string;
|
|
28
|
+
/** The resolved Workers VPC Service binding — the caller reads it off its own `EnvStore`
|
|
29
|
+
* (`env.vpc('PROMETHEUS_VPC')`), not a binding name for this client to look up itself. */
|
|
30
|
+
vpc: Fetcher;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The real implementation — pushes over Prometheus's remote-write protocol. Construct directly
|
|
34
|
+
* via `new PrometheusClient(config)`. */
|
|
35
|
+
export class PrometheusClient implements IPrometheusClient {
|
|
36
|
+
constructor(private readonly config: PrometheusConfig) {}
|
|
37
|
+
|
|
38
|
+
/** Zero samples is a real no-op (nothing to push). */
|
|
39
|
+
async push(...samples: PrometheusSample[]): Promise<void> {
|
|
40
|
+
if (samples.length === 0) return;
|
|
41
|
+
const { password, user, vpc } = this.config;
|
|
42
|
+
|
|
43
|
+
const now = BigInt(Date.now());
|
|
44
|
+
const writeRequest = create(WriteRequestSchema, {
|
|
45
|
+
timeseries: samples.map((s) => ({
|
|
46
|
+
labels: [
|
|
47
|
+
{ name: '__name__', value: s.name },
|
|
48
|
+
...Object.entries(s.labels ?? {}).map(([name, value]) => ({ name, value })),
|
|
49
|
+
],
|
|
50
|
+
samples: [
|
|
51
|
+
{ timestamp: s.timestampMs != null ? BigInt(s.timestampMs) : now, value: s.value },
|
|
52
|
+
],
|
|
53
|
+
})),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const res = await vpc.fetch('http://localhost/api/v1/write', {
|
|
57
|
+
body: snappy.compress(toBinary(WriteRequestSchema, writeRequest)),
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Basic ${btoa(`${user}:${password}`)}`,
|
|
60
|
+
'Content-Encoding': 'snappy',
|
|
61
|
+
'Content-Type': 'application/x-protobuf',
|
|
62
|
+
'X-Prometheus-Remote-Write-Version': '0.1.0',
|
|
63
|
+
},
|
|
64
|
+
method: 'POST',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`PrometheusClient.push: Prometheus returned ${res.status}: ${await res.text()}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Swallow-and-log — a Prometheus outage must never block the caller's own critical path.
|
|
75
|
+
* Extends its own lifetime past the caller's response where that's possible (the real
|
|
76
|
+
* Workers runtime), since without it the platform can kill this push mid-flight the moment
|
|
77
|
+
* the caller's own response has been sent. */
|
|
78
|
+
pushAndForget(...samples: PrometheusSample[]): void {
|
|
79
|
+
if (samples.length === 0) return;
|
|
80
|
+
const result = this.push(...samples).catch((error) => {
|
|
81
|
+
console.warn('metrics push failed', {
|
|
82
|
+
error: error instanceof Error ? error.message : String(error),
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
void waitUntil(result);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Retries with exponential backoff + jitter instead of swallowing — for a caller that needs
|
|
89
|
+
* the push to land. Still throws after exhausting `attempts`. */
|
|
90
|
+
async pushWithRetry(attempts = 3, ...samples: PrometheusSample[]): Promise<void> {
|
|
91
|
+
if (samples.length === 0) return;
|
|
92
|
+
for (let i = 0; ; i++) {
|
|
93
|
+
try {
|
|
94
|
+
return await this.push(...samples);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (i >= attempts - 1) throw error;
|
|
97
|
+
const backoff = 2 ** i * 100 + Math.random() * 100;
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Metric-type-shaped `PrometheusSample` builders — thin naming-convention wrappers, not real
|
|
105
|
+
// client-side accumulation (a Worker invocation is stateless; every call is one point-in-time
|
|
106
|
+
// observation the caller already computed). `bucket`/`sum` apply the `_bucket`/`_sum` suffixes
|
|
107
|
+
// Prometheus's histogram functions (`histogram_quantile`, `rate`) require to find the right
|
|
108
|
+
// series — `le` is the caller's own bucket boundary, folded into labels as a string (`"+Inf"` is
|
|
109
|
+
// a valid value for the top bucket). `count` does NOT auto-suffix `_total` — unlike `_bucket`/
|
|
110
|
+
// `_sum`, that's a naming convention `rate()`/`increase()` don't require, so it stays opt-in.
|
|
111
|
+
|
|
112
|
+
export function bucket(
|
|
113
|
+
name: string,
|
|
114
|
+
le: number | string,
|
|
115
|
+
value: number,
|
|
116
|
+
labels?: Record<string, string>,
|
|
117
|
+
timestampMs?: number
|
|
118
|
+
): PrometheusSample {
|
|
119
|
+
return { labels: { ...labels, le: String(le) }, name: `${name}_bucket`, timestampMs, value };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function count(
|
|
123
|
+
name: string,
|
|
124
|
+
value: number,
|
|
125
|
+
labels?: Record<string, string>,
|
|
126
|
+
timestampMs?: number
|
|
127
|
+
): PrometheusSample {
|
|
128
|
+
return { labels, name, timestampMs, value };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function gauge(
|
|
132
|
+
name: string,
|
|
133
|
+
value: number,
|
|
134
|
+
labels?: Record<string, string>,
|
|
135
|
+
timestampMs?: number
|
|
136
|
+
): PrometheusSample {
|
|
137
|
+
return { labels, name, timestampMs, value };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function sum(
|
|
141
|
+
name: string,
|
|
142
|
+
value: number,
|
|
143
|
+
labels?: Record<string, string>,
|
|
144
|
+
timestampMs?: number
|
|
145
|
+
): PrometheusSample {
|
|
146
|
+
return { labels, name: `${name}_sum`, timestampMs, value };
|
|
147
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IPrometheusClient, PrometheusSample } from './client';
|
|
2
|
+
|
|
3
|
+
/** Stands in for `PrometheusClient` where there's no `PROMETHEUS_VPC` binding to push to (local
|
|
4
|
+
* dev) — logs instead of sending, so metrics are still visible without a real Prometheus. */
|
|
5
|
+
export class DevPrometheusClient implements IPrometheusClient {
|
|
6
|
+
async push(...samples: PrometheusSample[]): Promise<void> {
|
|
7
|
+
this.log(samples);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
pushAndForget(...samples: PrometheusSample[]): void {
|
|
11
|
+
this.log(samples);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async pushWithRetry(_attempts?: number, ...samples: PrometheusSample[]): Promise<void> {
|
|
15
|
+
this.log(samples);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private log(samples: PrometheusSample[]): void {
|
|
19
|
+
for (const s of samples) {
|
|
20
|
+
console.log(`[prometheus:dev] ${s.name} ${s.value}`, s.labels ?? {});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Minimal Prometheus remote_write v1 wire schema — only the fields the workers actually send
|
|
2
|
+
// (a plain gauge sample per series, no exemplars/histograms/metadata). Field numbers match upstream
|
|
3
|
+
// prometheus/prometheus's prompb/{types,remote}.proto exactly, so the wire bytes are compatible; the
|
|
4
|
+
// gogoproto annotations there are Go-codegen-only and don't affect the wire format, so this file
|
|
5
|
+
// omits that dependency entirely.
|
|
6
|
+
|
|
7
|
+
// @generated by protoc-gen-es v2.16.0 with parameter "target=ts"
|
|
8
|
+
// @generated from file remote_write.proto (package zeyo.prometheus.remote_write.v1, syntax proto3)
|
|
9
|
+
/* eslint-disable */
|
|
10
|
+
|
|
11
|
+
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
|
12
|
+
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
|
13
|
+
import type { Message } from "@bufbuild/protobuf";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Describes the file remote_write.proto.
|
|
17
|
+
*/
|
|
18
|
+
export const file_remote_write: GenFile = /*@__PURE__*/
|
|
19
|
+
fileDesc("ChJyZW1vdGVfd3JpdGUucHJvdG8SH3pleW8ucHJvbWV0aGV1cy5yZW1vdGVfd3JpdGUudjEiJAoFTGFiZWwSDAoEbmFtZRgBIAEoCRINCgV2YWx1ZRgCIAEoCSIqCgZTYW1wbGUSDQoFdmFsdWUYASABKAESEQoJdGltZXN0YW1wGAIgASgDIn4KClRpbWVTZXJpZXMSNgoGbGFiZWxzGAEgAygLMiYuemV5by5wcm9tZXRoZXVzLnJlbW90ZV93cml0ZS52MS5MYWJlbBI4CgdzYW1wbGVzGAIgAygLMicuemV5by5wcm9tZXRoZXVzLnJlbW90ZV93cml0ZS52MS5TYW1wbGUiTwoMV3JpdGVSZXF1ZXN0Ej8KCnRpbWVzZXJpZXMYASADKAsyKy56ZXlvLnByb21ldGhldXMucmVtb3RlX3dyaXRlLnYxLlRpbWVTZXJpZXNiBnByb3RvMw");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @generated from message zeyo.prometheus.remote_write.v1.Label
|
|
23
|
+
*/
|
|
24
|
+
export type Label = Message<"zeyo.prometheus.remote_write.v1.Label"> & {
|
|
25
|
+
/**
|
|
26
|
+
* @generated from field: string name = 1;
|
|
27
|
+
*/
|
|
28
|
+
name: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @generated from field: string value = 2;
|
|
32
|
+
*/
|
|
33
|
+
value: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Describes the message zeyo.prometheus.remote_write.v1.Label.
|
|
38
|
+
* Use `create(LabelSchema)` to create a new message.
|
|
39
|
+
*/
|
|
40
|
+
export const LabelSchema: GenMessage<Label> = /*@__PURE__*/
|
|
41
|
+
messageDesc(file_remote_write, 0);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @generated from message zeyo.prometheus.remote_write.v1.Sample
|
|
45
|
+
*/
|
|
46
|
+
export type Sample = Message<"zeyo.prometheus.remote_write.v1.Sample"> & {
|
|
47
|
+
/**
|
|
48
|
+
* @generated from field: double value = 1;
|
|
49
|
+
*/
|
|
50
|
+
value: number;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ms since epoch
|
|
54
|
+
*
|
|
55
|
+
* @generated from field: int64 timestamp = 2;
|
|
56
|
+
*/
|
|
57
|
+
timestamp: bigint;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Describes the message zeyo.prometheus.remote_write.v1.Sample.
|
|
62
|
+
* Use `create(SampleSchema)` to create a new message.
|
|
63
|
+
*/
|
|
64
|
+
export const SampleSchema: GenMessage<Sample> = /*@__PURE__*/
|
|
65
|
+
messageDesc(file_remote_write, 1);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @generated from message zeyo.prometheus.remote_write.v1.TimeSeries
|
|
69
|
+
*/
|
|
70
|
+
export type TimeSeries = Message<"zeyo.prometheus.remote_write.v1.TimeSeries"> & {
|
|
71
|
+
/**
|
|
72
|
+
* @generated from field: repeated zeyo.prometheus.remote_write.v1.Label labels = 1;
|
|
73
|
+
*/
|
|
74
|
+
labels: Label[];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @generated from field: repeated zeyo.prometheus.remote_write.v1.Sample samples = 2;
|
|
78
|
+
*/
|
|
79
|
+
samples: Sample[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Describes the message zeyo.prometheus.remote_write.v1.TimeSeries.
|
|
84
|
+
* Use `create(TimeSeriesSchema)` to create a new message.
|
|
85
|
+
*/
|
|
86
|
+
export const TimeSeriesSchema: GenMessage<TimeSeries> = /*@__PURE__*/
|
|
87
|
+
messageDesc(file_remote_write, 2);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @generated from message zeyo.prometheus.remote_write.v1.WriteRequest
|
|
91
|
+
*/
|
|
92
|
+
export type WriteRequest = Message<"zeyo.prometheus.remote_write.v1.WriteRequest"> & {
|
|
93
|
+
/**
|
|
94
|
+
* @generated from field: repeated zeyo.prometheus.remote_write.v1.TimeSeries timeseries = 1;
|
|
95
|
+
*/
|
|
96
|
+
timeseries: TimeSeries[];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Describes the message zeyo.prometheus.remote_write.v1.WriteRequest.
|
|
101
|
+
* Use `create(WriteRequestSchema)` to create a new message.
|
|
102
|
+
*/
|
|
103
|
+
export const WriteRequestSchema: GenMessage<WriteRequest> = /*@__PURE__*/
|
|
104
|
+
messageDesc(file_remote_write, 3);
|
|
105
|
+
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { Handle } from '@sveltejs/kit/hooks';
|
|
2
|
+
|
|
3
|
+
import type { WorkerMiddleware } from '../../cloudflare/workers';
|
|
4
|
+
import type { EnvStore } from '../../utils/env';
|
|
5
|
+
|
|
6
|
+
import { runWithContext } from '../../cloudflare/context';
|
|
7
|
+
// Workers analog of rust-common's RequestMetrics/track_metrics — pushes rather than scrapes.
|
|
8
|
+
import { waitUntil } from '../../cloudflare/wait-until';
|
|
9
|
+
import { envStore } from '../../utils/env';
|
|
10
|
+
import { count, gauge, type IPrometheusClient, PrometheusClient } from './client';
|
|
11
|
+
|
|
12
|
+
/** Does nothing — the fallback `client()` returns when `PROMETHEUS_*` isn't configured in this
|
|
13
|
+
* env, so a caller expecting "swallow-and-log" fire-and-forget metrics (see `pushAndForget`'s own
|
|
14
|
+
* doc comment) doesn't get an unhandled construction throw instead. */
|
|
15
|
+
const NOOP_CLIENT: IPrometheusClient = {
|
|
16
|
+
push: async () => {},
|
|
17
|
+
pushAndForget: () => {},
|
|
18
|
+
pushWithRetry: async () => {},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let instance: IPrometheusClient | null = null;
|
|
22
|
+
|
|
23
|
+
/** Isolate-level singleton, not per-request — the binding is isolate-stable. Private to this
|
|
24
|
+
* file: the generic middleware below has no `env` param to thread through, so it needs its own
|
|
25
|
+
* ambient access; a caller with an `env` in hand should construct `PrometheusClient` directly.
|
|
26
|
+
*
|
|
27
|
+
* Construction itself (reading `PROMETHEUS_*` secrets/bindings) can throw when this env has none
|
|
28
|
+
* configured — local dev, most notably. Every caller here treats metrics as fire-and-forget, so
|
|
29
|
+
* that must degrade to a no-op instead of throwing: an unhandled construction error surfaces as
|
|
30
|
+
* an unhandled promise rejection in `pushRequestCount`'s async body, which crashes the whole
|
|
31
|
+
* process outside the real Workers runtime (Node kills on unhandled rejection by default). */
|
|
32
|
+
function client(): IPrometheusClient {
|
|
33
|
+
if (instance) return instance;
|
|
34
|
+
try {
|
|
35
|
+
const env = envStore();
|
|
36
|
+
return (instance = new PrometheusClient({
|
|
37
|
+
password: env.secret('PROMETHEUS_PASSWORD'),
|
|
38
|
+
user: env.string('PROMETHEUS_USER'),
|
|
39
|
+
vpc: env.vpc('PROMETHEUS_VPC'),
|
|
40
|
+
}));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.warn('Prometheus metrics disabled — PROMETHEUS_* not configured in this env', {
|
|
43
|
+
error: error instanceof Error ? error.message : String(error),
|
|
44
|
+
});
|
|
45
|
+
return (instance = NOOP_CLIENT);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `colo` splits this metric per Cloudflare colo, so concurrent pushes from different colos don't race Prometheus's per-series ordering and get rejected as "out of order". */
|
|
50
|
+
async function pushRequestCount(
|
|
51
|
+
service: string,
|
|
52
|
+
method: string,
|
|
53
|
+
route: string,
|
|
54
|
+
status: number,
|
|
55
|
+
colo?: string
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
client().pushAndForget(
|
|
58
|
+
count('platform_http_requests_total', 1, {
|
|
59
|
+
method,
|
|
60
|
+
route,
|
|
61
|
+
service,
|
|
62
|
+
status: String(status),
|
|
63
|
+
...(colo ? { colo } : {}),
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const Metrics = {
|
|
69
|
+
/** Add to `sequence(...)` right after `contextHandle`. Falls back to fire-and-forget on apps
|
|
70
|
+
* with no `Platform.ctx` (marketing, ui). */
|
|
71
|
+
trackHTTP(service: string): Handle {
|
|
72
|
+
return async ({ event, resolve }) => {
|
|
73
|
+
const response = await resolve(event);
|
|
74
|
+
const push = pushRequestCount(
|
|
75
|
+
service,
|
|
76
|
+
event.request.method,
|
|
77
|
+
event.route.id ?? 'unmatched',
|
|
78
|
+
response.status,
|
|
79
|
+
(event.request as Request & { cf?: { colo?: string } }).cf?.colo
|
|
80
|
+
);
|
|
81
|
+
void waitUntil(push);
|
|
82
|
+
return response;
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** One call wires whichever surfaces a router has: HTTP request counts, `.queue(...)`
|
|
87
|
+
* success/failure, and `.cron(...)` success/failure + last-run — `WorkerRouter.use()`/
|
|
88
|
+
* `WorkerBindingRouter.use()` each apply only the fields that surface actually has.
|
|
89
|
+
*/
|
|
90
|
+
WorkerMiddleware<Env>(service: string): WorkerMiddleware<Env> {
|
|
91
|
+
return {
|
|
92
|
+
cron: async (cron, _env, _executionCtx, next) => {
|
|
93
|
+
try {
|
|
94
|
+
await next();
|
|
95
|
+
client().pushAndForget(
|
|
96
|
+
count('platform_cron_runs_total', 1, { cron, outcome: 'ok', service })
|
|
97
|
+
);
|
|
98
|
+
client().pushAndForget(
|
|
99
|
+
gauge('platform_cron_last_run_timestamp_ms', Date.now(), { cron, service })
|
|
100
|
+
);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
client().pushAndForget(
|
|
103
|
+
count('platform_cron_runs_total', 1, { cron, outcome: 'error', service })
|
|
104
|
+
);
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
http: async (c, next) => {
|
|
109
|
+
const response = await next();
|
|
110
|
+
const colo = (c.req.cf as undefined | { colo?: string })?.colo;
|
|
111
|
+
c.executionCtx.waitUntil(
|
|
112
|
+
pushRequestCount(service, c.req.method, c.route, response.status, colo)
|
|
113
|
+
);
|
|
114
|
+
return response;
|
|
115
|
+
},
|
|
116
|
+
queue: async (queueName, _env, _executionCtx, next) => {
|
|
117
|
+
try {
|
|
118
|
+
await next();
|
|
119
|
+
client().pushAndForget(
|
|
120
|
+
count('platform_queue_messages_total', 1, { outcome: 'ok', queue: queueName, service })
|
|
121
|
+
);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
client().pushAndForget(
|
|
124
|
+
count('platform_queue_messages_total', 1, {
|
|
125
|
+
outcome: 'error',
|
|
126
|
+
queue: queueName,
|
|
127
|
+
service,
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/** For a `TickingDurableObject.onTick()` implementation (`.../workers/ticking-do`) that wants
|
|
137
|
+
* to push `platform_do_tick_runs_total`/`platform_do_tick_last_run_timestamp_ms`. */
|
|
138
|
+
TickingDO(service: string, env: EnvStore): { onTick(outcome: 'error' | 'ok'): void } {
|
|
139
|
+
const client = new PrometheusClient({
|
|
140
|
+
password: env.secret('PROMETHEUS_PASSWORD'),
|
|
141
|
+
user: env.string('PROMETHEUS_USER'),
|
|
142
|
+
vpc: env.vpc('PROMETHEUS_VPC'),
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
onTick(outcome) {
|
|
146
|
+
client.pushAndForget(count('platform_do_tick_runs_total', 1, { outcome, service }));
|
|
147
|
+
if (outcome === 'ok') {
|
|
148
|
+
client.pushAndForget(
|
|
149
|
+
gauge('platform_do_tick_last_run_timestamp_ms', Date.now(), { service })
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/** Plain-Worker `fetch` wrapper. `classifyRoute` collapses the path onto a fixed route set (no
|
|
157
|
+
* router here to supply one, and an unbounded label would blow up cardinality). `secretNames`:
|
|
158
|
+
* the app's secret-store binding names. */
|
|
159
|
+
withHTTP<Env extends object>(
|
|
160
|
+
service: string,
|
|
161
|
+
classifyRoute: (path: string) => string,
|
|
162
|
+
secretNames: ReadonlySet<string>,
|
|
163
|
+
handler: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
|
|
164
|
+
): (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response> {
|
|
165
|
+
return (request, env, ctx) =>
|
|
166
|
+
runWithContext(env, secretNames, async () => {
|
|
167
|
+
const response = await handler(request, env, ctx);
|
|
168
|
+
ctx.waitUntil(
|
|
169
|
+
pushRequestCount(
|
|
170
|
+
service,
|
|
171
|
+
request.method,
|
|
172
|
+
classifyRoute(new URL(request.url).pathname),
|
|
173
|
+
response.status,
|
|
174
|
+
(request.cf as undefined | { colo?: string })?.colo
|
|
175
|
+
)
|
|
176
|
+
);
|
|
177
|
+
return response;
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IPrometheusClient, PrometheusSample } from './client';
|
|
2
|
+
|
|
3
|
+
/** Records every sample passed to `push`/`pushAndForget`/`pushWithRetry` instead of sending
|
|
4
|
+
* anything — for asserting what a service pushed, without a mock `vpc.fetch`. */
|
|
5
|
+
export class FakePrometheusClient implements IPrometheusClient {
|
|
6
|
+
readonly pushed: PrometheusSample[] = [];
|
|
7
|
+
|
|
8
|
+
async push(...samples: PrometheusSample[]): Promise<void> {
|
|
9
|
+
this.pushed.push(...samples);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
pushAndForget(...samples: PrometheusSample[]): void {
|
|
13
|
+
this.pushed.push(...samples);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async pushWithRetry(_attempts?: number, ...samples: PrometheusSample[]): Promise<void> {
|
|
17
|
+
this.pushed.push(...samples);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Generic Slack Web API + Events API primitives — no app domain knowledge. `SlackClient`
|
|
2
|
+
// (./client.ts) is the separate Incoming-Webhook poster; this file is the bot-token
|
|
3
|
+
// (`chat.postMessage`) side plus request-signature verification.
|
|
4
|
+
|
|
5
|
+
/** Post a message (or thread reply) to a Slack channel via the bot token. Returns the posted
|
|
6
|
+
* message's `ts`. */
|
|
7
|
+
export async function slackPostMessage(
|
|
8
|
+
token: string,
|
|
9
|
+
params: {
|
|
10
|
+
blocks?: Record<string, unknown>[];
|
|
11
|
+
channel: string;
|
|
12
|
+
icon_url?: string;
|
|
13
|
+
text: string;
|
|
14
|
+
thread_ts?: string;
|
|
15
|
+
username?: string;
|
|
16
|
+
}
|
|
17
|
+
): Promise<{ channel: string; ts: string }> {
|
|
18
|
+
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
19
|
+
body: JSON.stringify(params),
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
},
|
|
24
|
+
method: 'POST',
|
|
25
|
+
});
|
|
26
|
+
const data = (await res.json()) as { channel: string; error?: string; ok: boolean; ts: string };
|
|
27
|
+
if (!data.ok) throw new Error(`Slack postMessage: ${data.error ?? 'unknown error'}`);
|
|
28
|
+
return { channel: data.channel, ts: data.ts };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Verify a Slack request signature (v0 scheme, HMAC-SHA256). Returns false when the timestamp
|
|
33
|
+
* is stale (> 5 minutes) or the computed signature does not match.
|
|
34
|
+
*/
|
|
35
|
+
export async function verifySlackSignature(
|
|
36
|
+
signingSecret: string,
|
|
37
|
+
timestamp: string,
|
|
38
|
+
rawBody: string,
|
|
39
|
+
signature: string
|
|
40
|
+
): Promise<boolean> {
|
|
41
|
+
// Guard against replay attacks.
|
|
42
|
+
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
|
|
43
|
+
|
|
44
|
+
const key = await crypto.subtle.importKey(
|
|
45
|
+
'raw',
|
|
46
|
+
new TextEncoder().encode(signingSecret),
|
|
47
|
+
{ hash: 'SHA-256', name: 'HMAC' },
|
|
48
|
+
false,
|
|
49
|
+
['sign']
|
|
50
|
+
);
|
|
51
|
+
const base = `v0:${timestamp}:${rawBody}`;
|
|
52
|
+
const buf = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(base));
|
|
53
|
+
const hex = Array.from(new Uint8Array(buf))
|
|
54
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
55
|
+
.join('');
|
|
56
|
+
return timingSafeEqual(signature, `v0=${hex}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Web Crypto has no `timingSafeEqual` — compare every byte instead of short-circuiting on the
|
|
60
|
+
// first mismatch, so response timing can't leak how many signature bytes an attacker got right.
|
|
61
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
let diff = 0;
|
|
64
|
+
for (let i = 0; i < a.length; i++) {
|
|
65
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
66
|
+
}
|
|
67
|
+
return diff === 0;
|
|
68
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EnvStore } from '../../utils/env';
|
|
2
|
+
|
|
3
|
+
import { logger } from '../logger/client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Posts a message to a Slack channel via an incoming webhook. Never throws on a failed post —
|
|
7
|
+
* losing an alert must not fail the caller's actual work — but a webhook request failure (network,
|
|
8
|
+
* non-2xx) is still logged.
|
|
9
|
+
*/
|
|
10
|
+
export class SlackClient {
|
|
11
|
+
constructor(private readonly webhookUrl: string) {}
|
|
12
|
+
|
|
13
|
+
/** `blocks` is optional, for a richer message than plain `text`. */
|
|
14
|
+
async post(payload: { blocks?: Record<string, unknown>[]; text: string }): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(this.webhookUrl, {
|
|
17
|
+
body: JSON.stringify(payload),
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
method: 'POST',
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) throw new Error(`Slack webhook responded ${res.status}`);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
await logger.error(err, { alert: payload.text });
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Built from `SLACK_OPS_WEBHOOK_URL` off the ambient env store. */
|
|
31
|
+
export function slackClient(env: EnvStore): SlackClient {
|
|
32
|
+
return new SlackClient(env.string('SLACK_OPS_WEBHOOK_URL'));
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Slack Events API envelope shapes — generic wire format, no app domain knowledge.
|
|
2
|
+
export type SlackMessageEvent = {
|
|
3
|
+
bot_id?: string;
|
|
4
|
+
channel: string;
|
|
5
|
+
subtype?: string;
|
|
6
|
+
text: string;
|
|
7
|
+
thread_ts?: string;
|
|
8
|
+
ts: string;
|
|
9
|
+
type: 'message';
|
|
10
|
+
user?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SlackPayload =
|
|
14
|
+
| { challenge: string; type: 'url_verification' }
|
|
15
|
+
| { event: SlackMessageEvent; event_id: string; type: 'event_callback' };
|