slotlock 0.6.2 → 0.7.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.
- package/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/otel.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SlotlockAgentServerEvent } from './agent-server.js';
|
|
2
|
+
import type { SlotlockMetrics } from './metrics.js';
|
|
3
|
+
export interface SlotlockOtlpConfig {
|
|
4
|
+
/** Where spans are POSTed: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, or OTEL_EXPORTER_OTLP_ENDPOINT + /v1/traces. */
|
|
5
|
+
tracesUrl: string;
|
|
6
|
+
/** Sent with each export (OTEL_EXPORTER_OTLP_HEADERS), such as a collector's API key. */
|
|
7
|
+
headers: Readonly<Record<string, string>>;
|
|
8
|
+
/** service.name (OTEL_SERVICE_NAME), `slotlock` by default. */
|
|
9
|
+
serviceName: string;
|
|
10
|
+
/**
|
|
11
|
+
* Where metrics are POSTed: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, or OTEL_EXPORTER_OTLP_ENDPOINT +
|
|
12
|
+
* /v1/metrics; none with only a traces endpoint.
|
|
13
|
+
*/
|
|
14
|
+
metricsUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SlotlockTraceExporter {
|
|
17
|
+
/** Turn an agent server event into a span, if it is a call; a child of `parentSpanId` when given. */
|
|
18
|
+
record(event: SlotlockAgentServerEvent, parentSpanId?: string): void;
|
|
19
|
+
/** Send what is queued now. */
|
|
20
|
+
flush(): Promise<void>;
|
|
21
|
+
/** Stop the timer and send what is left. */
|
|
22
|
+
shutdown(): Promise<void>;
|
|
23
|
+
/** Spans dropped because the queue was full or the collector refused or did not answer. */
|
|
24
|
+
readonly dropped: number;
|
|
25
|
+
}
|
|
26
|
+
interface Span {
|
|
27
|
+
traceId: string;
|
|
28
|
+
spanId: string;
|
|
29
|
+
parentSpanId?: string;
|
|
30
|
+
name: string;
|
|
31
|
+
kind: 2;
|
|
32
|
+
startTimeUnixNano: string;
|
|
33
|
+
endTimeUnixNano: string;
|
|
34
|
+
attributes: {
|
|
35
|
+
key: string;
|
|
36
|
+
value: {
|
|
37
|
+
stringValue: string;
|
|
38
|
+
} | {
|
|
39
|
+
intValue: string;
|
|
40
|
+
};
|
|
41
|
+
}[];
|
|
42
|
+
status: {
|
|
43
|
+
code: 1 | 2;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The span a call event makes, or null for any other event. */
|
|
47
|
+
export declare function slotlockCallSpan(event: SlotlockAgentServerEvent, endedAtMs: number,
|
|
48
|
+
/** The caller's span from its traceparent: the call's span is its child. */
|
|
49
|
+
parentSpanId?: string): Span | null;
|
|
50
|
+
/** Read the standard OTEL_* variables; undefined when no endpoint is set. */
|
|
51
|
+
export declare function readSlotlockOtlpConfig(env: Readonly<Record<string, string | undefined>>): SlotlockOtlpConfig | undefined;
|
|
52
|
+
/** Batch spans to an OTLP/HTTP collector as JSON. */
|
|
53
|
+
export declare function createSlotlockOtlpExporter(config: SlotlockOtlpConfig,
|
|
54
|
+
/**
|
|
55
|
+
* The service's version is not sent: the deployed one is on /healthz and /metrics, and an export
|
|
56
|
+
* carries nothing read from a file.
|
|
57
|
+
*/
|
|
58
|
+
options: {
|
|
59
|
+
fetch?: typeof fetch;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
intervalMs?: number;
|
|
62
|
+
maxBatch?: number;
|
|
63
|
+
maxQueue?: number;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
/** Told when an export fails: its HTTP status, or that the collector did not answer. Never a header. */
|
|
66
|
+
onError?: (reason: string) => void;
|
|
67
|
+
}): SlotlockTraceExporter;
|
|
68
|
+
/** The snapshot of a server's metrics as an OTLP/HTTP JSON body: cumulative sums, histograms, gauges. */
|
|
69
|
+
export declare function slotlockOtlpMetricsBody(metrics: ReturnType<SlotlockMetrics['collect']>, serviceName: string, nowMs: number): string;
|
|
70
|
+
/**
|
|
71
|
+
* Send a server's metrics to an OTLP/HTTP collector every `intervalMs` (60 seconds), cumulative
|
|
72
|
+
* since the server started, as the scrape at /metrics counts them; a collector that is down costs
|
|
73
|
+
* that export, never a request.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createSlotlockOtlpMetricsExporter(config: SlotlockOtlpConfig & {
|
|
76
|
+
metricsUrl: string;
|
|
77
|
+
}, metrics: SlotlockMetrics, options?: {
|
|
78
|
+
fetch?: typeof fetch;
|
|
79
|
+
now?: () => number;
|
|
80
|
+
intervalMs?: number;
|
|
81
|
+
onError?: (reason: string) => void;
|
|
82
|
+
}): {
|
|
83
|
+
flush(): Promise<void>;
|
|
84
|
+
shutdown(): Promise<void>;
|
|
85
|
+
};
|
|
86
|
+
export {};
|
package/otel.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// OpenTelemetry trace export for `slotlock serve` (RFC 0002 P2-5, RFC 0003 Later): one SERVER span
|
|
2
|
+
// per agent and REST call, sent to an OTLP/HTTP collector as JSON (OTEL_EXPORTER_OTLP_ENDPOINT).
|
|
3
|
+
// A call that carried a valid `traceparent` joins the caller's trace as a child of the caller's
|
|
4
|
+
// span, which reaches the exporter through the agent server's onCallTrace alone, never its events
|
|
5
|
+
// or logs.
|
|
6
|
+
//
|
|
7
|
+
// What a span says is what /metrics and the logs already say: the protocol, the operation, the
|
|
8
|
+
// outcome or HTTP status, and how long it took. Never a tenant, a key, an argument or an address.
|
|
9
|
+
// Spans wait in a bounded queue and go in batches; a collector that is down or slow costs dropped
|
|
10
|
+
// spans, counted, never a request.
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
const HEX_TRACE = /^[0-9a-f]{32}$/;
|
|
13
|
+
/** Outcomes that are the server's failure, not the caller's: the span is an error. */
|
|
14
|
+
const SERVER_FAILURES = new Set(['internal_error', 'unavailable']);
|
|
15
|
+
const nanos = (ms) => (BigInt(Math.round(ms * 1_000)) * 1000n).toString();
|
|
16
|
+
/** The span a call event makes, or null for any other event. */
|
|
17
|
+
export function slotlockCallSpan(event, endedAtMs,
|
|
18
|
+
/** The caller's span from its traceparent: the call's span is its child. */
|
|
19
|
+
parentSpanId) {
|
|
20
|
+
if (event.type !== 'call' && event.type !== 'rest')
|
|
21
|
+
return null;
|
|
22
|
+
const protocol = event.type === 'rest' ? 'rest' : event.protocol;
|
|
23
|
+
const failed = event.type === 'rest' ? event.status >= 500 : SERVER_FAILURES.has(event.outcome);
|
|
24
|
+
const traceId = event.traceId !== undefined && HEX_TRACE.test(event.traceId) ? event.traceId : randomBytes(16).toString('hex');
|
|
25
|
+
const parent = event.traceId !== undefined && parentSpanId !== undefined && /^[0-9a-f]{16}$/.test(parentSpanId) && parentSpanId !== '0'.repeat(16)
|
|
26
|
+
? parentSpanId
|
|
27
|
+
: undefined;
|
|
28
|
+
return {
|
|
29
|
+
traceId,
|
|
30
|
+
spanId: randomBytes(8).toString('hex'),
|
|
31
|
+
...(parent ? { parentSpanId: parent } : {}),
|
|
32
|
+
name: `${protocol} ${event.operation}`,
|
|
33
|
+
kind: 2,
|
|
34
|
+
startTimeUnixNano: nanos(endedAtMs - Math.max(0, event.durationMs)),
|
|
35
|
+
endTimeUnixNano: nanos(endedAtMs),
|
|
36
|
+
attributes: [
|
|
37
|
+
{ key: 'slotlock.protocol', value: { stringValue: protocol } },
|
|
38
|
+
{ key: 'slotlock.operation', value: { stringValue: event.operation } },
|
|
39
|
+
event.type === 'rest'
|
|
40
|
+
? { key: 'http.response.status_code', value: { intValue: String(event.status) } }
|
|
41
|
+
: { key: 'slotlock.outcome', value: { stringValue: event.outcome } },
|
|
42
|
+
],
|
|
43
|
+
status: { code: failed ? 2 : 1 },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Read the standard OTEL_* variables; undefined when no endpoint is set. */
|
|
47
|
+
export function readSlotlockOtlpConfig(env) {
|
|
48
|
+
// Each read whether or not export is on, so `slotlock --help` lists exactly what is read.
|
|
49
|
+
const traces = env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim();
|
|
50
|
+
const metrics = env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT?.trim();
|
|
51
|
+
const base = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
|
|
52
|
+
const headerList = env.OTEL_EXPORTER_OTLP_HEADERS ?? '';
|
|
53
|
+
const service = env.OTEL_SERVICE_NAME?.trim();
|
|
54
|
+
if (!traces && !base && !metrics)
|
|
55
|
+
return undefined;
|
|
56
|
+
// Trailing slashes trimmed by hand: a regular expression for it backtracks on a long run of them.
|
|
57
|
+
let trimmed = base ?? '';
|
|
58
|
+
while (trimmed.endsWith('/'))
|
|
59
|
+
trimmed = trimmed.slice(0, -1);
|
|
60
|
+
const checked = (url) => {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(url);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw Object.assign(new Error('OTEL_EXPORTER_OTLP_ENDPOINT must be an http or https URL'), { code: 'invalid_otlp_endpoint' });
|
|
67
|
+
}
|
|
68
|
+
if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username || parsed.password) {
|
|
69
|
+
throw Object.assign(new Error('OTEL_EXPORTER_OTLP_ENDPOINT must be an http or https URL without credentials'), {
|
|
70
|
+
code: 'invalid_otlp_endpoint',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return parsed.href;
|
|
74
|
+
};
|
|
75
|
+
const tracesUrl = traces ? checked(traces) : base ? checked(`${trimmed}/v1/traces`) : '';
|
|
76
|
+
const metricsUrl = metrics ? checked(metrics) : base ? checked(`${trimmed}/v1/metrics`) : undefined;
|
|
77
|
+
const headers = {};
|
|
78
|
+
for (const part of headerList.split(',')) {
|
|
79
|
+
if (part.trim() === '')
|
|
80
|
+
continue;
|
|
81
|
+
const at = part.indexOf('=');
|
|
82
|
+
const name = at > 0 ? part.slice(0, at).trim() : '';
|
|
83
|
+
let value = '';
|
|
84
|
+
try {
|
|
85
|
+
value = decodeURIComponent(part.slice(at + 1).trim());
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
value = '';
|
|
89
|
+
}
|
|
90
|
+
if (!/^[A-Za-z0-9-]{1,100}$/.test(name) || value === '' || /[\r\n]/.test(value)) {
|
|
91
|
+
throw Object.assign(new Error('OTEL_EXPORTER_OTLP_HEADERS must be comma-separated name=value pairs'), {
|
|
92
|
+
code: 'invalid_otlp_headers',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
headers[name.toLowerCase()] = value;
|
|
96
|
+
}
|
|
97
|
+
const serviceName = service || 'slotlock';
|
|
98
|
+
if (!/^[\x21-\x7e]{1,100}$/.test(serviceName)) {
|
|
99
|
+
throw Object.assign(new Error('OTEL_SERVICE_NAME must be 1-100 printable characters'), { code: 'invalid_otlp_service' });
|
|
100
|
+
}
|
|
101
|
+
return { tracesUrl, headers, serviceName, ...(metricsUrl ? { metricsUrl } : {}) };
|
|
102
|
+
}
|
|
103
|
+
/** Batch spans to an OTLP/HTTP collector as JSON. */
|
|
104
|
+
export function createSlotlockOtlpExporter(config,
|
|
105
|
+
/**
|
|
106
|
+
* The service's version is not sent: the deployed one is on /healthz and /metrics, and an export
|
|
107
|
+
* carries nothing read from a file.
|
|
108
|
+
*/
|
|
109
|
+
options) {
|
|
110
|
+
const send = options.fetch ?? fetch;
|
|
111
|
+
const now = options.now ?? Date.now;
|
|
112
|
+
const maxBatch = options.maxBatch ?? 256;
|
|
113
|
+
const maxQueue = options.maxQueue ?? 4_096;
|
|
114
|
+
const queue = [];
|
|
115
|
+
let dropped = 0;
|
|
116
|
+
let sending = Promise.resolve();
|
|
117
|
+
const resource = {
|
|
118
|
+
attributes: [
|
|
119
|
+
{ key: 'service.name', value: { stringValue: config.serviceName } },
|
|
120
|
+
{ key: 'telemetry.sdk.name', value: { stringValue: 'slotlock' } },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
async function exportBatch(spans) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await send(config.tracesUrl, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { ...config.headers, 'content-type': 'application/json' },
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
resourceSpans: [{ resource, scopeSpans: [{ scope: { name: 'slotlock' }, spans }] }],
|
|
130
|
+
}),
|
|
131
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
dropped += spans.length;
|
|
135
|
+
options.onError?.(`collector answered HTTP ${response.status}`);
|
|
136
|
+
}
|
|
137
|
+
await response.body?.cancel().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
dropped += spans.length;
|
|
141
|
+
options.onError?.('collector did not answer');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function flush() {
|
|
145
|
+
// One export at a time, in order.
|
|
146
|
+
sending = sending.then(async () => {
|
|
147
|
+
while (queue.length > 0)
|
|
148
|
+
await exportBatch(queue.splice(0, maxBatch));
|
|
149
|
+
});
|
|
150
|
+
return sending;
|
|
151
|
+
}
|
|
152
|
+
const timer = setInterval(() => void flush(), options.intervalMs ?? 5_000);
|
|
153
|
+
timer.unref?.();
|
|
154
|
+
return {
|
|
155
|
+
record(event, parentSpanId) {
|
|
156
|
+
const span = slotlockCallSpan(event, now(), parentSpanId);
|
|
157
|
+
if (!span)
|
|
158
|
+
return;
|
|
159
|
+
if (queue.length >= maxQueue) {
|
|
160
|
+
dropped += 1;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
queue.push(span);
|
|
164
|
+
if (queue.length >= maxBatch)
|
|
165
|
+
void flush();
|
|
166
|
+
},
|
|
167
|
+
flush,
|
|
168
|
+
async shutdown() {
|
|
169
|
+
clearInterval(timer);
|
|
170
|
+
await flush();
|
|
171
|
+
},
|
|
172
|
+
get dropped() {
|
|
173
|
+
return dropped;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** The snapshot of a server's metrics as an OTLP/HTTP JSON body: cumulative sums, histograms, gauges. */
|
|
178
|
+
export function slotlockOtlpMetricsBody(metrics, serviceName, nowMs) {
|
|
179
|
+
const start = nanos(metrics.startedAtMs);
|
|
180
|
+
const time = nanos(nowMs);
|
|
181
|
+
const attributes = (labels) => Object.entries(labels).map(([key, value]) => ({ key, value: { stringValue: value } }));
|
|
182
|
+
return JSON.stringify({
|
|
183
|
+
resourceMetrics: [
|
|
184
|
+
{
|
|
185
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
186
|
+
scopeMetrics: [
|
|
187
|
+
{
|
|
188
|
+
scope: { name: 'slotlock' },
|
|
189
|
+
metrics: [
|
|
190
|
+
...metrics.counters.map((counter) => ({
|
|
191
|
+
name: counter.name,
|
|
192
|
+
description: counter.help,
|
|
193
|
+
sum: {
|
|
194
|
+
aggregationTemporality: 2,
|
|
195
|
+
isMonotonic: true,
|
|
196
|
+
dataPoints: counter.points.map((point) => ({
|
|
197
|
+
attributes: attributes(point.labels),
|
|
198
|
+
startTimeUnixNano: start,
|
|
199
|
+
timeUnixNano: time,
|
|
200
|
+
asDouble: point.value,
|
|
201
|
+
})),
|
|
202
|
+
},
|
|
203
|
+
})),
|
|
204
|
+
...metrics.histograms.map((histogram) => ({
|
|
205
|
+
name: histogram.name,
|
|
206
|
+
description: histogram.help,
|
|
207
|
+
unit: 's',
|
|
208
|
+
histogram: {
|
|
209
|
+
aggregationTemporality: 2,
|
|
210
|
+
dataPoints: histogram.points.map((point) => ({
|
|
211
|
+
attributes: attributes(point.labels),
|
|
212
|
+
startTimeUnixNano: start,
|
|
213
|
+
timeUnixNano: time,
|
|
214
|
+
count: String(point.count),
|
|
215
|
+
sum: point.sum,
|
|
216
|
+
explicitBounds: [...histogram.bounds],
|
|
217
|
+
// OTLP counts each bucket on its own; the scrape's are cumulative.
|
|
218
|
+
bucketCounts: [...point.cumulative.map((count, index) => count - (index === 0 ? 0 : (point.cumulative[index - 1] ?? 0))), point.count - (point.cumulative.at(-1) ?? 0)].map(String),
|
|
219
|
+
})),
|
|
220
|
+
},
|
|
221
|
+
})),
|
|
222
|
+
...metrics.gauges.map((gauge) => ({
|
|
223
|
+
name: gauge.name,
|
|
224
|
+
description: gauge.help,
|
|
225
|
+
gauge: {
|
|
226
|
+
dataPoints: gauge.points.map((point) => ({ attributes: attributes(point.labels), timeUnixNano: time, asDouble: point.value })),
|
|
227
|
+
},
|
|
228
|
+
})),
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Send a server's metrics to an OTLP/HTTP collector every `intervalMs` (60 seconds), cumulative
|
|
238
|
+
* since the server started, as the scrape at /metrics counts them; a collector that is down costs
|
|
239
|
+
* that export, never a request.
|
|
240
|
+
*/
|
|
241
|
+
export function createSlotlockOtlpMetricsExporter(config, metrics, options = {}) {
|
|
242
|
+
const send = options.fetch ?? fetch;
|
|
243
|
+
const now = options.now ?? Date.now;
|
|
244
|
+
let sending = Promise.resolve();
|
|
245
|
+
const flush = () => {
|
|
246
|
+
sending = sending.then(async () => {
|
|
247
|
+
try {
|
|
248
|
+
const response = await send(config.metricsUrl, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { ...config.headers, 'content-type': 'application/json' },
|
|
251
|
+
body: slotlockOtlpMetricsBody(metrics.collect(), config.serviceName, now()),
|
|
252
|
+
signal: AbortSignal.timeout(10_000),
|
|
253
|
+
});
|
|
254
|
+
if (!response.ok)
|
|
255
|
+
options.onError?.(`collector answered HTTP ${response.status}`);
|
|
256
|
+
await response.body?.cancel().catch(() => undefined);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
options.onError?.('collector did not answer');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return sending;
|
|
263
|
+
};
|
|
264
|
+
const timer = setInterval(() => void flush(), options.intervalMs ?? 60_000);
|
|
265
|
+
timer.unref?.();
|
|
266
|
+
return {
|
|
267
|
+
flush,
|
|
268
|
+
async shutdown() {
|
|
269
|
+
clearInterval(timer);
|
|
270
|
+
await flush();
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
package/overage.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Sql } from 'postgres';
|
|
2
|
+
/** The stop percent overage moves a paying Developer tenant to unless SLOTLOCK_OVERAGE_STOP_PERCENT says. */
|
|
3
|
+
export declare const SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT = 300;
|
|
4
|
+
export interface SlotlockOverageReport {
|
|
5
|
+
month: string;
|
|
6
|
+
/** Tenants with operations above their quota this month, and what each was billed now. */
|
|
7
|
+
tenants: {
|
|
8
|
+
tenant: string;
|
|
9
|
+
over: number;
|
|
10
|
+
reported: number;
|
|
11
|
+
sent: number;
|
|
12
|
+
}[];
|
|
13
|
+
/** Operations sent to Stripe in this run. */
|
|
14
|
+
sent: number;
|
|
15
|
+
/** Tenants whose event Stripe did not take (run again). */
|
|
16
|
+
failed: number;
|
|
17
|
+
}
|
|
18
|
+
/** `YYYY-MM` of a time, UTC. */
|
|
19
|
+
export declare function slotlockMonthOf(at: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* Report each paying Developer tenant's operations above its quota in `month` to Stripe, once.
|
|
22
|
+
* Runs as the schema owner. A month can be reported while it runs and once more after it ends.
|
|
23
|
+
*/
|
|
24
|
+
export declare function reportSlotlockOverage(sql: Sql, input: {
|
|
25
|
+
month: string;
|
|
26
|
+
stripe: {
|
|
27
|
+
secretKey: string;
|
|
28
|
+
eventName: string;
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
apiBase?: string;
|
|
31
|
+
};
|
|
32
|
+
now?: number;
|
|
33
|
+
}): Promise<SlotlockOverageReport>;
|
package/overage.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Metered overage for Developer (RFC 0003 "Later"), off unless SLOTLOCK_OVERAGE=on. Stripe's test
|
|
2
|
+
// mode takes the metered price in a Managed Payments checkout (read 2026-11-10, RFC 0003 vendor
|
|
3
|
+
// facts); it stays off until a paid test subscription shows the meter billed and the Terms say what
|
|
4
|
+
// overage costs.
|
|
5
|
+
//
|
|
6
|
+
// With it on: a paying Developer tenant is not refused at 110% of its monthly operations but at
|
|
7
|
+
// SLOTLOCK_OVERAGE_STOP_PERCENT (300% by default); checkout adds the metered price
|
|
8
|
+
// (SLOTLOCK_STRIPE_PRICE_OVERAGE) to its subscription; and `slotlock overage report`, run by the
|
|
9
|
+
// owner (daily, and on the 1st for the month before), sends Stripe a Billing Meter event for each
|
|
10
|
+
// tenant's operations above its quota that it has not reported yet. What was reported is recorded
|
|
11
|
+
// in slotlock.overage_reports first by month, so no operation is billed twice, and each event
|
|
12
|
+
// carries an identifier Stripe deduplicates within a day: Stripe refusing one as already taken counts
|
|
13
|
+
// as sent.
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { SLOTLOCK_STRIPE_API_VERSION } from './billing.js';
|
|
16
|
+
import { SLOTLOCK_PLAN_QUOTAS } from './plans.js';
|
|
17
|
+
/** The stop percent overage moves a paying Developer tenant to unless SLOTLOCK_OVERAGE_STOP_PERCENT says. */
|
|
18
|
+
export const SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT = 300;
|
|
19
|
+
/** `YYYY-MM` of a time, UTC. */
|
|
20
|
+
export function slotlockMonthOf(at) {
|
|
21
|
+
return new Date(at).toISOString().slice(0, 7);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Report each paying Developer tenant's operations above its quota in `month` to Stripe, once.
|
|
25
|
+
* Runs as the schema owner. A month can be reported while it runs and once more after it ends.
|
|
26
|
+
*/
|
|
27
|
+
export async function reportSlotlockOverage(sql, input) {
|
|
28
|
+
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.month)) {
|
|
29
|
+
throw Object.assign(new Error('Slotlock month must be YYYY-MM'), { code: 'invalid_month' });
|
|
30
|
+
}
|
|
31
|
+
if (!/^[A-Za-z0-9_-]{1,100}$/.test(input.stripe.eventName)) {
|
|
32
|
+
throw Object.assign(new Error('Slotlock overage meter event name must be 1-100 of A-Z, a-z, 0-9, _ and -'), {
|
|
33
|
+
code: 'invalid_meter_event',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const from = `${input.month}-01`;
|
|
37
|
+
const [year, monthNumber] = input.month.split('-').map(Number);
|
|
38
|
+
// The month's last second: a closed month's events are dated in it.
|
|
39
|
+
const monthEnd = Date.UTC(year, monthNumber, 1) - 1_000;
|
|
40
|
+
const quota = SLOTLOCK_PLAN_QUOTAS.developer.operations;
|
|
41
|
+
// Paying Developer tenants (their own plan, a live subscription) and what they used this month.
|
|
42
|
+
const rows = await sql `
|
|
43
|
+
SELECT plan.tenant_ref, plan.stripe_customer AS customer,
|
|
44
|
+
COALESCE((SELECT sum(used.count) FROM slotlock.usage_daily used
|
|
45
|
+
WHERE used.tenant_ref = plan.tenant_ref AND used.metric = 'operations'
|
|
46
|
+
AND used.day >= ${from}::date
|
|
47
|
+
AND used.day < (${from}::date + interval '1 month')), 0)::text AS used,
|
|
48
|
+
(SELECT done.reported FROM slotlock.overage_reports done
|
|
49
|
+
WHERE done.tenant_ref = plan.tenant_ref AND done.month = ${from}::date)::text AS reported
|
|
50
|
+
FROM slotlock.tenant_plans plan
|
|
51
|
+
WHERE plan.plan = 'developer' AND plan.source = 'direct'
|
|
52
|
+
AND plan.stripe_customer IS NOT NULL AND plan.stripe_subscription IS NOT NULL
|
|
53
|
+
AND plan.status <> 'canceled'
|
|
54
|
+
ORDER BY plan.tenant_ref`;
|
|
55
|
+
const send = input.stripe.fetch ?? fetch;
|
|
56
|
+
const report = { month: input.month, tenants: [], sent: 0, failed: 0 };
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const over = Math.max(0, Number(row.used) - quota);
|
|
59
|
+
const reported = Number(row.reported ?? 0);
|
|
60
|
+
if (over <= reported) {
|
|
61
|
+
if (over > 0)
|
|
62
|
+
report.tenants.push({ tenant: row.tenant_ref, over, reported, sent: 0 });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const delta = over - reported;
|
|
66
|
+
// Recorded first: a run that stops between the two bills too little, never twice.
|
|
67
|
+
await sql `
|
|
68
|
+
INSERT INTO slotlock.overage_reports (tenant_ref, month, reported)
|
|
69
|
+
VALUES (${row.tenant_ref}, ${from}::date, ${over})
|
|
70
|
+
ON CONFLICT (tenant_ref, month) DO UPDATE
|
|
71
|
+
SET reported = GREATEST(slotlock.overage_reports.reported, EXCLUDED.reported), updated_at = statement_timestamp()`;
|
|
72
|
+
// The same total gives the same identifier, so a retry within Stripe's window is one event.
|
|
73
|
+
const identifier = `slotlock-overage-${createHash('sha256').update(row.tenant_ref).digest('hex').slice(0, 24)}-${input.month}-${over}`;
|
|
74
|
+
const response = await send(`${input.stripe.apiBase ?? 'https://api.stripe.com'}/v1/billing/meter_events`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
authorization: `Bearer ${input.stripe.secretKey}`,
|
|
78
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
79
|
+
'stripe-version': SLOTLOCK_STRIPE_API_VERSION,
|
|
80
|
+
},
|
|
81
|
+
body: new URLSearchParams({
|
|
82
|
+
event_name: input.stripe.eventName,
|
|
83
|
+
identifier,
|
|
84
|
+
'payload[stripe_customer_id]': row.customer,
|
|
85
|
+
'payload[value]': String(delta),
|
|
86
|
+
// Within the month it is billed in; a closed month's are dated its last second.
|
|
87
|
+
timestamp: String(Math.floor(Math.min(input.now ?? Date.now(), monthEnd) / 1_000)),
|
|
88
|
+
}).toString(),
|
|
89
|
+
}).catch(() => null);
|
|
90
|
+
if (!response?.ok && !(await takenBefore(response, identifier))) {
|
|
91
|
+
// Not taken: put the record back, so the next run sends it.
|
|
92
|
+
await sql `
|
|
93
|
+
UPDATE slotlock.overage_reports SET reported = ${reported}, updated_at = statement_timestamp()
|
|
94
|
+
WHERE tenant_ref = ${row.tenant_ref} AND month = ${from}::date AND reported = ${over}`;
|
|
95
|
+
report.failed += 1;
|
|
96
|
+
report.tenants.push({ tenant: row.tenant_ref, over, reported, sent: 0 });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
report.sent += delta;
|
|
100
|
+
report.tenants.push({ tenant: row.tenant_ref, over, reported: over, sent: delta });
|
|
101
|
+
}
|
|
102
|
+
return report;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether Stripe refused an event only because it took one with this identifier before: a run whose
|
|
106
|
+
* answer was lost sends it again, and Stripe says so with a 400 and no code (read in test mode,
|
|
107
|
+
* 2026-11-10). Counted as sent, or the record would go back and the next total bill this delta twice.
|
|
108
|
+
*/
|
|
109
|
+
async function takenBefore(response, identifier) {
|
|
110
|
+
if (response?.status !== 400)
|
|
111
|
+
return false;
|
|
112
|
+
const body = (await response.json().catch(() => null));
|
|
113
|
+
return (body?.error?.type === 'invalid_request_error' &&
|
|
114
|
+
body.error.message === `An event already exists with identifier ${identifier}.`);
|
|
115
|
+
}
|
package/package-version.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// root one), which is where it is installed from npm or copied into the image; run from a checkout's
|
|
4
|
-
// src/, the root package.json is one directory up.
|
|
1
|
+
// Read from package.json so that file is the one place the version is set: built, the artifact's
|
|
2
|
+
// own package.json sits beside this module; in a checkout's src/, the root one is a directory up.
|
|
5
3
|
import { readFileSync } from 'node:fs';
|
|
6
4
|
/** Slotlock's version, or `'unknown'` where no manifest of its own can be read (a bundle). */
|
|
7
5
|
export function slotlockPackageVersion() {
|
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Sql } from 'postgres';
|
|
2
|
+
import { type SlotlockPartnerContract } from './partners.js';
|
|
3
|
+
import { type SlotlockWebhookSender } from './webhooks.js';
|
|
4
|
+
/** One account's month: each metric's total (resources: the most it held on a day). */
|
|
5
|
+
export interface SlotlockPartnerAccountMonth {
|
|
6
|
+
account: string;
|
|
7
|
+
plan: string;
|
|
8
|
+
totals: Readonly<Record<string, number>>;
|
|
9
|
+
}
|
|
10
|
+
/** One invoice line, in the contract currency's minor units. */
|
|
11
|
+
export interface SlotlockPartnerInvoiceLine {
|
|
12
|
+
line: string;
|
|
13
|
+
description: string;
|
|
14
|
+
quantity: number;
|
|
15
|
+
amount: number;
|
|
16
|
+
currency: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Price a month with the contract (P6): each account with at least one operation, each 10,000
|
|
20
|
+
* operations and each 100 bookings an account made above its plan's quota (each account rounded up
|
|
21
|
+
* on its own), and the minimum's shortfall. No pricing in the contract: no lines.
|
|
22
|
+
*/
|
|
23
|
+
export declare function priceSlotlockPartnerMonth(contract: SlotlockPartnerContract, accounts: readonly SlotlockPartnerAccountMonth[]): SlotlockPartnerInvoiceLine[];
|
|
24
|
+
/** The SHA-256 (hex) of a month's canonical rows: `account\tmetric\tcount` lines, sorted, then the invoice lines. */
|
|
25
|
+
export declare function slotlockPartnerMonthChecksum(accounts: readonly SlotlockPartnerAccountMonth[], lines: readonly SlotlockPartnerInvoiceLine[]): string;
|
|
26
|
+
/** `YYYY-MM`, a month that has ended by `now` (UTC): its first day and the next month's. */
|
|
27
|
+
export declare function slotlockClosedMonth(month: string, now: number): {
|
|
28
|
+
from: string;
|
|
29
|
+
to: string;
|
|
30
|
+
next: string;
|
|
31
|
+
};
|
|
32
|
+
/** The month before `now` (UTC), `YYYY-MM`: what a run on the 1st closes. */
|
|
33
|
+
export declare function slotlockPreviousMonth(now: number): string;
|
|
34
|
+
export interface SlotlockPartnerMonthClose {
|
|
35
|
+
partner: string;
|
|
36
|
+
month: string;
|
|
37
|
+
/** Each account's totals as the month was first closed: later runs send the same. */
|
|
38
|
+
accounts: SlotlockPartnerAccountMonth[];
|
|
39
|
+
lines: (SlotlockPartnerInvoiceLine & {
|
|
40
|
+
stripeItem: string | null;
|
|
41
|
+
})[];
|
|
42
|
+
checksum: string;
|
|
43
|
+
/** True when this run closed the month; false when an earlier run had. */
|
|
44
|
+
closedNow: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The webhook: delivered in this run (a 2xx), delivered by an earlier run, not configured, or
|
|
47
|
+
* failed (no answer, or another status, in `webhookStatus`), to be sent again by the next run.
|
|
48
|
+
*/
|
|
49
|
+
webhook: 'delivered' | 'already_delivered' | 'no_webhook' | 'failed';
|
|
50
|
+
webhookStatus?: number;
|
|
51
|
+
/** Lines Stripe took in this run, and those still waiting for it (no Stripe key, or a failure). */
|
|
52
|
+
invoiced: number;
|
|
53
|
+
pending: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Close one partner's month, as the schema owner (`slotlock partner close`). The first run freezes
|
|
57
|
+
* it: each account's totals (erased accounts' included), the invoice lines the contract prices them
|
|
58
|
+
* at and their checksum, in one transaction. Every run then sends usage.period_closed with that same
|
|
59
|
+
* body until the partner's endpoint takes it (a 2xx), and adds to the partner's next Stripe invoice
|
|
60
|
+
* each line Stripe has not taken, adopting one it took in a run that stopped before recording it.
|
|
61
|
+
* Nothing is priced or billed twice, however often or concurrently it runs.
|
|
62
|
+
*/
|
|
63
|
+
export declare function closeSlotlockPartnerMonth(sql: Sql, input: {
|
|
64
|
+
partnerId: string;
|
|
65
|
+
month: string;
|
|
66
|
+
now?: number;
|
|
67
|
+
/** Stripe: a secret key with read and write access to invoice items. Without it, lines wait. */
|
|
68
|
+
stripe?: {
|
|
69
|
+
secretKey: string;
|
|
70
|
+
fetch?: typeof fetch;
|
|
71
|
+
apiBase?: string;
|
|
72
|
+
};
|
|
73
|
+
/** How the webhook is sent (createSlotlockWebhookSender, which refuses private addresses). */
|
|
74
|
+
send?: SlotlockWebhookSender;
|
|
75
|
+
}): Promise<SlotlockPartnerMonthClose>;
|
|
76
|
+
/**
|
|
77
|
+
* The `Slotlock-Client` request field (P5), as metrics count it: the name before any `/version`,
|
|
78
|
+
* when it is one of the deployment's known integrators (SLOTLOCK_KNOWN_CLIENTS), else `other`, and
|
|
79
|
+
* `none` without the field. Never anything else from the request.
|
|
80
|
+
*/
|
|
81
|
+
export declare function slotlockClientLabel(field: string | null, known: ReadonlySet<string>): string;
|