@lunora/container 1.0.0-alpha.1 → 1.0.0-alpha.11
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/LICENSE.md +26 -0
- package/README.md +97 -1
- package/__assets__/package-og.svg +1 -1
- package/dist/bridge.d.mts +3 -3
- package/dist/bridge.d.ts +3 -3
- package/dist/bridge.mjs +10 -7
- package/dist/do/index.d.mts +558 -4
- package/dist/do/index.d.ts +558 -4
- package/dist/do/index.mjs +213 -11
- package/dist/index.d.mts +112 -21
- package/dist/index.d.ts +112 -21
- package/dist/index.mjs +2 -2
- package/dist/otel.d.mts +118 -0
- package/dist/otel.d.ts +118 -0
- package/dist/otel.mjs +220 -0
- package/dist/packem_shared/ContainerProxy-DWqUX_re.mjs +1474 -0
- package/dist/packem_shared/containerBindingName-BiTrAF1J.mjs +224 -0
- package/dist/packem_shared/createContainerContext-Cg53QGdf.mjs +223 -0
- package/dist/packem_shared/jurisdiction-CuPNcLDt.mjs +13 -0
- package/dist/packem_shared/jurisdiction.d-TwTGkgTg.d.mts +266 -0
- package/dist/packem_shared/jurisdiction.d-TwTGkgTg.d.ts +266 -0
- package/package.json +7 -3
- package/dist/packem_shared/containerBindingName-BGdSdFNA.mjs +0 -116
- package/dist/packem_shared/createContainerContext-ChDD53ys.mjs +0 -122
- package/dist/packem_shared/types.d-D2l2SYol.d.mts +0 -140
- package/dist/packem_shared/types.d-D2l2SYol.d.ts +0 -140
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export type { B as BuildImageSource, c as ContainerInstanceType, d as
|
|
3
|
-
/**
|
|
4
|
-
* The `ctx.containers` action surface: typed handles over the `CONTAINER_*`
|
|
5
|
-
* Durable Object namespace bindings the config layer reconciles.
|
|
6
|
-
*
|
|
7
|
-
* Deliberately structural (no `@cloudflare/containers` import): a Durable
|
|
8
|
-
* Object namespace stub is all that is needed to route a request to a
|
|
9
|
-
* container-enabled DO, so this module stays Node-safe and the test double
|
|
10
|
-
* below can satisfy the exact same shape without a workerd runtime.
|
|
11
|
-
*/
|
|
1
|
+
import { D as DurableObjectJurisdiction, C as ContainerConfig, a as ContainerDefinition, b as ContainerImageSource, N as NormalizedContainerImage } from "./packem_shared/jurisdiction.d-TwTGkgTg.js";
|
|
2
|
+
export type { B as BuildImageSource, c as ContainerInstanceType, d as ContainerReadinessCheck, e as ContainerRollout, f as CustomContainerInstanceType, g as NamedContainerInstanceType, R as RegistryImageSource } from "./packem_shared/jurisdiction.d-TwTGkgTg.js";
|
|
12
3
|
/** Options for explicitly starting an instance (mirrors `@cloudflare/containers`). */
|
|
13
4
|
interface ContainerStartOptions {
|
|
14
5
|
/** Override outbound internet access for this start. */
|
|
@@ -23,13 +14,25 @@ interface ContainerStartOptions {
|
|
|
23
14
|
/** A container instance's runtime state, as returned by `getState()`. Structural — the platform adds fields over time. */
|
|
24
15
|
interface ContainerInstanceState {
|
|
25
16
|
[key: string]: unknown;
|
|
17
|
+
/** Process exit code, present once the instance has `stopped_with_code`. */
|
|
18
|
+
exitCode?: number;
|
|
19
|
+
/** Epoch-ms of the last state transition. */
|
|
26
20
|
lastChange?: number;
|
|
21
|
+
/** Lifecycle status. Widening union — Cloudflare adds values over time. */
|
|
22
|
+
status?: "healthy" | "running" | "stopped" | "stopped_with_code" | "stopping";
|
|
27
23
|
}
|
|
28
|
-
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle RPCs the container DO exposes. */
|
|
24
|
+
/** What a handle needs from a Durable Object stub — `fetch` plus the optional lifecycle/egress RPCs the container DO exposes. */
|
|
29
25
|
interface ContainerStubLike {
|
|
26
|
+
allowHost?: (hostname: string) => Promise<void>;
|
|
27
|
+
denyHost?: (hostname: string) => Promise<void>;
|
|
30
28
|
destroy?: () => Promise<void>;
|
|
31
29
|
fetch: (input: Request) => Promise<Response>;
|
|
32
30
|
getState?: () => Promise<ContainerInstanceState>;
|
|
31
|
+
removeAllowedHost?: (hostname: string) => Promise<void>;
|
|
32
|
+
removeDeniedHost?: (hostname: string) => Promise<void>;
|
|
33
|
+
renewActivityTimeout?: () => Promise<void>;
|
|
34
|
+
setAllowedHosts?: (hosts: string[]) => Promise<void>;
|
|
35
|
+
setDeniedHosts?: (hosts: string[]) => Promise<void>;
|
|
33
36
|
start?: (options?: ContainerStartOptions) => Promise<void>;
|
|
34
37
|
stop?: (signal?: number | string) => Promise<void>;
|
|
35
38
|
}
|
|
@@ -37,6 +40,11 @@ interface ContainerStubLike {
|
|
|
37
40
|
interface ContainerNamespaceLike {
|
|
38
41
|
get: (id: unknown) => ContainerStubLike;
|
|
39
42
|
idFromName: (name: string) => unknown;
|
|
43
|
+
/**
|
|
44
|
+
* Derive a jurisdiction-restricted subnamespace. Optional because older
|
|
45
|
+
* workers-types releases (and test doubles) may not expose it.
|
|
46
|
+
*/
|
|
47
|
+
jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ContainerNamespaceLike;
|
|
40
48
|
}
|
|
41
49
|
/** A handle on one container instance (one Durable Object). */
|
|
42
50
|
interface ContainerHandle {
|
|
@@ -46,6 +54,15 @@ interface ContainerHandle {
|
|
|
46
54
|
* `Request`/URL passes through unchanged.
|
|
47
55
|
*/
|
|
48
56
|
fetch: (input: Request | string, init?: RequestInit) => Promise<Response>;
|
|
57
|
+
/**
|
|
58
|
+
* Return a handle that routes every request to `targetPort` on the
|
|
59
|
+
* container instead of the definition's `defaultPort` — for multi-port
|
|
60
|
+
* containers (declare the ports in `requiredPorts`). Sets the
|
|
61
|
+
* `cf-container-target-port` header the way `@cloudflare/containers`'
|
|
62
|
+
* `switchPort` does, so it composes with `.get()`, `.any()`, and `.pool()`:
|
|
63
|
+
* `ctx.containers.app.get("u1").port(9090).fetch("/admin")`.
|
|
64
|
+
*/
|
|
65
|
+
port: (targetPort: number) => ContainerHandle;
|
|
49
66
|
}
|
|
50
67
|
/**
|
|
51
68
|
* A handle on a *named* instance (from `.get(name)`) — `fetch` plus explicit
|
|
@@ -57,23 +74,76 @@ interface ContainerHandle {
|
|
|
57
74
|
interface ContainerInstanceHandle extends ContainerHandle {
|
|
58
75
|
/** Stop and discard the instance (its ephemeral disk is lost). */
|
|
59
76
|
destroy: () => Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Adjust this instance's egress allow/deny lists at runtime — the dynamic
|
|
79
|
+
* counterpart to the static `allowedHosts`/`deniedHosts` config. Useful for
|
|
80
|
+
* per-tenant egress policy. Requires the worker to export `ContainerProxy`
|
|
81
|
+
* (codegen re-exports it from the generated container file whenever any
|
|
82
|
+
* container is defined, so the runtime controls always work).
|
|
83
|
+
*/
|
|
84
|
+
egress: ContainerEgressControls;
|
|
60
85
|
/** Read the instance's current runtime state. */
|
|
61
86
|
getState: () => Promise<ContainerInstanceState>;
|
|
87
|
+
/**
|
|
88
|
+
* Reset the instance's `sleepAfter` idle timer. The platform renews it on
|
|
89
|
+
* each proxied request, and because `@lunora/container` proxies WebSocket
|
|
90
|
+
* frames through the Durable Object, message traffic on an open socket
|
|
91
|
+
* renews it too (the WebSocket-keepalive gap of cloudflare/containers#147 is
|
|
92
|
+
* closed in the bundled base). This manual control is the escape hatch for
|
|
93
|
+
* keeping a container awake during activity that is neither an HTTP request
|
|
94
|
+
* nor a WS message — e.g. a long out-of-band job running inside it.
|
|
95
|
+
*/
|
|
96
|
+
renewActivityTimeout: () => Promise<void>;
|
|
62
97
|
/** Explicitly start the instance, optionally with per-instance env/entrypoint. */
|
|
63
98
|
start: (options?: ContainerStartOptions) => Promise<void>;
|
|
64
99
|
/** Stop the instance (optionally with a signal); it can start again on the next request. */
|
|
65
100
|
stop: (signal?: number | string) => Promise<void>;
|
|
66
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Runtime egress-firewall controls for a named instance (`handle.egress.*`).
|
|
104
|
+
* Each maps to the corresponding `@cloudflare/containers` `Container` RPC, so
|
|
105
|
+
* an app can tighten or relax a single instance's allowed/denied hosts after
|
|
106
|
+
* start without redeploying.
|
|
107
|
+
*/
|
|
108
|
+
interface ContainerEgressControls {
|
|
109
|
+
/** Add one hostname (or glob) to the allow-list. */
|
|
110
|
+
allow: (hostname: string) => Promise<void>;
|
|
111
|
+
/** Add one hostname (or glob) to the deny-list. */
|
|
112
|
+
deny: (hostname: string) => Promise<void>;
|
|
113
|
+
/** Remove one hostname from the allow-list. */
|
|
114
|
+
removeAllowed: (hostname: string) => Promise<void>;
|
|
115
|
+
/** Remove one hostname from the deny-list. */
|
|
116
|
+
removeDenied: (hostname: string) => Promise<void>;
|
|
117
|
+
/** Replace the entire allow-list. */
|
|
118
|
+
setAllowed: (hosts: ReadonlyArray<string>) => Promise<void>;
|
|
119
|
+
/** Replace the entire deny-list. */
|
|
120
|
+
setDenied: (hosts: ReadonlyArray<string>) => Promise<void>;
|
|
121
|
+
}
|
|
67
122
|
/** The per-definition accessor exposed as `ctx.containers.<exportName>`. */
|
|
68
123
|
interface ContainerAccessor {
|
|
69
124
|
/**
|
|
70
125
|
* A random instance from a fixed pool of `count` (defaults to the
|
|
71
126
|
* definition's `maxInstances`, else 3 — mirroring `getRandom` from
|
|
72
127
|
* `@cloudflare/containers`). For stateless, interchangeable workloads.
|
|
128
|
+
*
|
|
129
|
+
* Like `.get()`, a path/URL-string fetch transparently retries the
|
|
130
|
+
* cold-start "instance is provisioning" transients (cloudflare/containers#45,
|
|
131
|
+
* #139); pass {@link InstanceRetryOptions} to tune or disable it.
|
|
132
|
+
*/
|
|
133
|
+
any: (count?: number, options?: InstanceRetryOptions) => ContainerHandle;
|
|
134
|
+
/**
|
|
135
|
+
* The instance for `name` — one container per entity (user, room, job…),
|
|
136
|
+
* with lifecycle control.
|
|
137
|
+
*
|
|
138
|
+
* A path/URL-string fetch transparently retries the platform's cold-start
|
|
139
|
+
* transients — "there is no Container instance available" / "container is
|
|
140
|
+
* not listening" while an instance is still provisioning
|
|
141
|
+
* (cloudflare/containers#45, #139) — on the *same* instance with backoff,
|
|
142
|
+
* since the request never reached the app. Pass {@link InstanceRetryOptions}
|
|
143
|
+
* to tune attempts/backoff or disable it (`{ attempts: 1 }`). A pre-built
|
|
144
|
+
* `Request` (possibly a one-shot stream body) is sent once, never retried.
|
|
73
145
|
*/
|
|
74
|
-
|
|
75
|
-
/** The instance for `name` — one container per entity (user, room, job…), with lifecycle control. */
|
|
76
|
-
get: (name: string) => ContainerInstanceHandle;
|
|
146
|
+
get: (name: string, options?: InstanceRetryOptions) => ContainerInstanceHandle;
|
|
77
147
|
/**
|
|
78
148
|
* A resilient handle over the pool: each `fetch` picks a random instance and,
|
|
79
149
|
* on a thrown error or a retryable response (5xx by default), retries on a
|
|
@@ -110,6 +180,24 @@ interface PoolOptions {
|
|
|
110
180
|
/** Pool size to spread picks across. Defaults to the definition's `maxInstances`, else 3. */
|
|
111
181
|
size?: number;
|
|
112
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Tuning for the cold-start retry on a `.get()`/`.any()` handle. The retry fires
|
|
185
|
+
* only on the platform's provisioning transients (no-instance / not-listening /
|
|
186
|
+
* rate-limited — see {@link isColdStartTransient}), which is why it's safe by
|
|
187
|
+
* default: those responses mean the request never reached the container.
|
|
188
|
+
*/
|
|
189
|
+
interface InstanceRetryOptions {
|
|
190
|
+
/**
|
|
191
|
+
* Total attempts on a cold-start transient before the last outcome is
|
|
192
|
+
* surfaced as-is. `1` disables the retry. Default
|
|
193
|
+
* {@link DEFAULT_COLD_START_ATTEMPTS}.
|
|
194
|
+
*/
|
|
195
|
+
attempts?: number;
|
|
196
|
+
/** Base backoff in ms between attempts; doubles each retry (0 disables the wait). Default {@link DEFAULT_COLD_START_BACKOFF_MS}. */
|
|
197
|
+
backoffMs?: number;
|
|
198
|
+
/** Upper bound on a single backoff sleep, in ms. Default {@link DEFAULT_MAX_BACKOFF_MS} (30s). */
|
|
199
|
+
maxBackoffMs?: number;
|
|
200
|
+
}
|
|
113
201
|
/** Wiring info for one definition, emitted by codegen into the generated DO. */
|
|
114
202
|
interface ContainerBindingSpec {
|
|
115
203
|
/** Durable Object binding name, e.g. `CONTAINER_TRANSCODER`. */
|
|
@@ -121,12 +209,15 @@ interface ContainerBindingSpec {
|
|
|
121
209
|
}
|
|
122
210
|
/**
|
|
123
211
|
* Build the `ctx.containers` record from the Worker `env`. Called by the
|
|
124
|
-
* generated ShardDO with the specs codegen derived from
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
212
|
+
* generated ShardDO with the specs codegen derived from `lunora/containers.ts`.
|
|
213
|
+
* A missing binding doesn't throw here — only when the handle is actually used —
|
|
214
|
+
* so one unprovisioned container never breaks unrelated functions.
|
|
215
|
+
*
|
|
216
|
+
* `traceparent` (the inbound RPC's W3C trace context, forwarded by the runtime
|
|
217
|
+
* and read off the request by the DO) is stamped onto every outbound container
|
|
218
|
+
* `fetch`, so the container's own spans stitch under the Worker's trace.
|
|
128
219
|
*/
|
|
129
|
-
declare const createContainerContext: (env: Record<string, unknown>, specs: ReadonlyArray<ContainerBindingSpec
|
|
220
|
+
declare const createContainerContext: (env: Record<string, unknown>, specs: ReadonlyArray<ContainerBindingSpec>, jurisdiction?: DurableObjectJurisdiction, traceparent?: string) => Record<string, ContainerAccessor>;
|
|
130
221
|
/** A test handler: receives the request plus the targeted instance name. */
|
|
131
222
|
type ContainerTestHandler = (request: Request, instance: {
|
|
132
223
|
name: string;
|
|
@@ -186,4 +277,4 @@ declare const isContainerDefinition: (value: unknown) => value is ContainerDefin
|
|
|
186
277
|
* credential it was promised yields far worse errors downstream.
|
|
187
278
|
*/
|
|
188
279
|
declare const resolveContainerEnvVariables: (definition: ContainerDefinition, workerEnv: Record<string, unknown>, exportName?: string) => Record<string, string>;
|
|
189
|
-
export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
|
280
|
+
export { type ContainerAccessor, type ContainerBindingSpec, type ContainerConfig, type ContainerDefinition, type ContainerEgressControls, type ContainerHandle, type ContainerImageSource, type ContainerInstanceHandle, type ContainerInstanceState, type ContainerNamespaceLike, type ContainerStartOptions, type ContainerTestHandler, type DurableObjectJurisdiction, type InstanceRetryOptions, type NormalizedContainerImage, type PoolOptions, containerBindingName, containerBuildTag, containerClassName, createContainerContext, createContainerTestContext, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVariables as resolveContainerEnvVars };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createContainerContext, createContainerTestContext } from './packem_shared/createContainerContext-
|
|
2
|
-
export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVars } from './packem_shared/containerBindingName-
|
|
1
|
+
export { createContainerContext, createContainerTestContext } from './packem_shared/createContainerContext-Cg53QGdf.mjs';
|
|
2
|
+
export { containerBindingName, containerBuildTag, containerClassName, defineContainer, isContainerDefinition, normalizeContainerImage, resolveContainerEnvVars } from './packem_shared/containerBindingName-BiTrAF1J.mjs';
|
package/dist/otel.d.mts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** An attribute value carried on a span or log. */
|
|
2
|
+
type ContainerAttributeValue = boolean | number | string;
|
|
3
|
+
/**
|
|
4
|
+
* A `fetch` implementation — defaults to the runtime global. The exporter passes
|
|
5
|
+
* an abort `signal` (for the per-request timeout) and, once the promise settles,
|
|
6
|
+
* cancels the response `body` so Node/undici can release the socket for
|
|
7
|
+
* keep-alive reuse instead of leaving it occupied by an unread stream. It reads
|
|
8
|
+
* `ok`/`status` to detect a rejected export and nothing else from the response.
|
|
9
|
+
*/
|
|
10
|
+
type OtelFetchLike = (input: string, init: {
|
|
11
|
+
body: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
method: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}) => Promise<{
|
|
16
|
+
body?: {
|
|
17
|
+
cancel: () => Promise<void>;
|
|
18
|
+
} | null;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
status: number;
|
|
21
|
+
}>;
|
|
22
|
+
/** A single span the container process asks the exporter to record. */
|
|
23
|
+
interface ContainerSpanInput {
|
|
24
|
+
/** Attributes attached to the span (rendered under the OTLP `attributes` list). */
|
|
25
|
+
attributes?: Record<string, ContainerAttributeValue>;
|
|
26
|
+
/** Wall-clock millis when the operation ended. */
|
|
27
|
+
endMs: number;
|
|
28
|
+
/** When set, the span is marked errored with this message (and optional `error.type`). */
|
|
29
|
+
error?: {
|
|
30
|
+
message: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
};
|
|
33
|
+
/** Span name — the operation being timed, e.g. `"transcode"`. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Wall-clock millis when the operation started. */
|
|
36
|
+
startMs: number;
|
|
37
|
+
}
|
|
38
|
+
/** A single log line the container process asks the exporter to record. */
|
|
39
|
+
interface ContainerLogInput {
|
|
40
|
+
/** Attributes attached to the log record. */
|
|
41
|
+
attributes?: Record<string, ContainerAttributeValue>;
|
|
42
|
+
/** Severity — defaults to `"info"`. */
|
|
43
|
+
level?: "debug" | "error" | "info" | "warn";
|
|
44
|
+
/** The log message body. */
|
|
45
|
+
message: string;
|
|
46
|
+
/** Wall-clock millis the line was emitted; defaults to now. */
|
|
47
|
+
ts?: number;
|
|
48
|
+
}
|
|
49
|
+
/** Options for {@link createContainerTelemetry}. */
|
|
50
|
+
interface ContainerTelemetryOptions {
|
|
51
|
+
/** Base OTLP collector endpoint; defaults to the `LUNORA_OTLP_ENDPOINT` env var. */
|
|
52
|
+
endpoint?: string;
|
|
53
|
+
/** Injectable `fetch` (tests / non-global runtimes). Defaults to `globalThis.fetch`. */
|
|
54
|
+
fetch?: OtelFetchLike;
|
|
55
|
+
/** Extra headers merged onto every POST — e.g. deployment/org correlation. `content-type` is set by default. */
|
|
56
|
+
headers?: Record<string, string>;
|
|
57
|
+
/** Called with any send failure so the caller can surface it; the export itself always swallows. */
|
|
58
|
+
onError?: (error: unknown) => void;
|
|
59
|
+
/** `service.name` resource attribute; defaults to the `LUNORA_SERVICE_NAME` env var then `"lunora-container"`. */
|
|
60
|
+
serviceName?: string;
|
|
61
|
+
/** Per-POST timeout in ms; a collector that never responds aborts after this so a stuck send can't stall `flush()`. Defaults to {@link DEFAULT_TIMEOUT_MS} (10s). */
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
/** Bearer token sent as an `Authorization: Bearer` header; defaults to the `LUNORA_OTLP_TOKEN` env var. */
|
|
64
|
+
token?: string;
|
|
65
|
+
/**
|
|
66
|
+
* W3C `traceparent` of the Worker RPC that invoked this container; defaults to
|
|
67
|
+
* the `LUNORA_TRACEPARENT` env var. When present (and well-formed) every span
|
|
68
|
+
* inherits its trace id and hangs off its span id, so container spans stitch
|
|
69
|
+
* under the Worker's trace instead of forming a fresh, disconnected trace.
|
|
70
|
+
*
|
|
71
|
+
* `@lunora/container` stamps this trace context as the **`traceparent` request
|
|
72
|
+
* header** on every proxied fetch (`ctx.containers.<name>.…`), so a container
|
|
73
|
+
* that serves many requests should read it per request and create a telemetry
|
|
74
|
+
* instance scoped to that request — the trace context differs each call, so a
|
|
75
|
+
* single process-lifetime instance can't carry it:
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* // inside the container's request handler
|
|
79
|
+
* const telemetry = createContainerTelemetry({ traceparent: request.headers.get("traceparent") ?? undefined });
|
|
80
|
+
* await telemetry.trace("transcode", () => transcode(job));
|
|
81
|
+
* await telemetry.flush();
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* The `LUNORA_TRACEPARENT` env fallback fits a one-shot container that
|
|
85
|
+
* processes a single job per start (the value is fixed for the process).
|
|
86
|
+
*/
|
|
87
|
+
traceparent?: string;
|
|
88
|
+
}
|
|
89
|
+
/** The exporter handle {@link createContainerTelemetry} returns. */
|
|
90
|
+
interface ContainerTelemetry {
|
|
91
|
+
/** Record one log line (no-op when disabled). */
|
|
92
|
+
emitLog: (log: ContainerLogInput) => void;
|
|
93
|
+
/** Record one span (no-op when disabled). */
|
|
94
|
+
emitSpan: (span: ContainerSpanInput) => void;
|
|
95
|
+
/** True when an endpoint resolved and exports are actually sent. */
|
|
96
|
+
readonly enabled: boolean;
|
|
97
|
+
/** Await all in-flight sends — call before the process exits. */
|
|
98
|
+
flush: () => Promise<void>;
|
|
99
|
+
/** Time `run()`, recording a span named `name` (ok, or errored if it throws). Always runs `run()`, even when disabled. */
|
|
100
|
+
trace: <T>(name: string, run: () => Promise<T>, attributes?: Record<string, ContainerAttributeValue>) => Promise<T>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Create a zero-config OTLP exporter for the container process.
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* const telemetry = createContainerTelemetry(); // reads LUNORA_OTLP_ENDPOINT / _TOKEN
|
|
107
|
+
* await telemetry.trace("transcode", () => transcode(job), { jobId: job.id });
|
|
108
|
+
* telemetry.emitLog({ level: "info", message: "done", attributes: { jobId: job.id } });
|
|
109
|
+
* await telemetry.flush(); // before the process exits
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* With no endpoint resolvable the returned exporter is disabled (`enabled ===
|
|
113
|
+
* false`): `emitSpan`/`emitLog` no-op and `trace` still runs its work but records
|
|
114
|
+
* nothing — so the same code runs unchanged locally and in the cloud.
|
|
115
|
+
* @param options Exporter options; every field falls back to a `LUNORA_*` env var.
|
|
116
|
+
*/
|
|
117
|
+
declare const createContainerTelemetry: (options?: ContainerTelemetryOptions) => ContainerTelemetry;
|
|
118
|
+
export { type ContainerAttributeValue, type ContainerLogInput, type ContainerSpanInput, type ContainerTelemetry, type ContainerTelemetryOptions, type OtelFetchLike, createContainerTelemetry };
|
package/dist/otel.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** An attribute value carried on a span or log. */
|
|
2
|
+
type ContainerAttributeValue = boolean | number | string;
|
|
3
|
+
/**
|
|
4
|
+
* A `fetch` implementation — defaults to the runtime global. The exporter passes
|
|
5
|
+
* an abort `signal` (for the per-request timeout) and, once the promise settles,
|
|
6
|
+
* cancels the response `body` so Node/undici can release the socket for
|
|
7
|
+
* keep-alive reuse instead of leaving it occupied by an unread stream. It reads
|
|
8
|
+
* `ok`/`status` to detect a rejected export and nothing else from the response.
|
|
9
|
+
*/
|
|
10
|
+
type OtelFetchLike = (input: string, init: {
|
|
11
|
+
body: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
method: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}) => Promise<{
|
|
16
|
+
body?: {
|
|
17
|
+
cancel: () => Promise<void>;
|
|
18
|
+
} | null;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
status: number;
|
|
21
|
+
}>;
|
|
22
|
+
/** A single span the container process asks the exporter to record. */
|
|
23
|
+
interface ContainerSpanInput {
|
|
24
|
+
/** Attributes attached to the span (rendered under the OTLP `attributes` list). */
|
|
25
|
+
attributes?: Record<string, ContainerAttributeValue>;
|
|
26
|
+
/** Wall-clock millis when the operation ended. */
|
|
27
|
+
endMs: number;
|
|
28
|
+
/** When set, the span is marked errored with this message (and optional `error.type`). */
|
|
29
|
+
error?: {
|
|
30
|
+
message: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
};
|
|
33
|
+
/** Span name — the operation being timed, e.g. `"transcode"`. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Wall-clock millis when the operation started. */
|
|
36
|
+
startMs: number;
|
|
37
|
+
}
|
|
38
|
+
/** A single log line the container process asks the exporter to record. */
|
|
39
|
+
interface ContainerLogInput {
|
|
40
|
+
/** Attributes attached to the log record. */
|
|
41
|
+
attributes?: Record<string, ContainerAttributeValue>;
|
|
42
|
+
/** Severity — defaults to `"info"`. */
|
|
43
|
+
level?: "debug" | "error" | "info" | "warn";
|
|
44
|
+
/** The log message body. */
|
|
45
|
+
message: string;
|
|
46
|
+
/** Wall-clock millis the line was emitted; defaults to now. */
|
|
47
|
+
ts?: number;
|
|
48
|
+
}
|
|
49
|
+
/** Options for {@link createContainerTelemetry}. */
|
|
50
|
+
interface ContainerTelemetryOptions {
|
|
51
|
+
/** Base OTLP collector endpoint; defaults to the `LUNORA_OTLP_ENDPOINT` env var. */
|
|
52
|
+
endpoint?: string;
|
|
53
|
+
/** Injectable `fetch` (tests / non-global runtimes). Defaults to `globalThis.fetch`. */
|
|
54
|
+
fetch?: OtelFetchLike;
|
|
55
|
+
/** Extra headers merged onto every POST — e.g. deployment/org correlation. `content-type` is set by default. */
|
|
56
|
+
headers?: Record<string, string>;
|
|
57
|
+
/** Called with any send failure so the caller can surface it; the export itself always swallows. */
|
|
58
|
+
onError?: (error: unknown) => void;
|
|
59
|
+
/** `service.name` resource attribute; defaults to the `LUNORA_SERVICE_NAME` env var then `"lunora-container"`. */
|
|
60
|
+
serviceName?: string;
|
|
61
|
+
/** Per-POST timeout in ms; a collector that never responds aborts after this so a stuck send can't stall `flush()`. Defaults to {@link DEFAULT_TIMEOUT_MS} (10s). */
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
/** Bearer token sent as an `Authorization: Bearer` header; defaults to the `LUNORA_OTLP_TOKEN` env var. */
|
|
64
|
+
token?: string;
|
|
65
|
+
/**
|
|
66
|
+
* W3C `traceparent` of the Worker RPC that invoked this container; defaults to
|
|
67
|
+
* the `LUNORA_TRACEPARENT` env var. When present (and well-formed) every span
|
|
68
|
+
* inherits its trace id and hangs off its span id, so container spans stitch
|
|
69
|
+
* under the Worker's trace instead of forming a fresh, disconnected trace.
|
|
70
|
+
*
|
|
71
|
+
* `@lunora/container` stamps this trace context as the **`traceparent` request
|
|
72
|
+
* header** on every proxied fetch (`ctx.containers.<name>.…`), so a container
|
|
73
|
+
* that serves many requests should read it per request and create a telemetry
|
|
74
|
+
* instance scoped to that request — the trace context differs each call, so a
|
|
75
|
+
* single process-lifetime instance can't carry it:
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* // inside the container's request handler
|
|
79
|
+
* const telemetry = createContainerTelemetry({ traceparent: request.headers.get("traceparent") ?? undefined });
|
|
80
|
+
* await telemetry.trace("transcode", () => transcode(job));
|
|
81
|
+
* await telemetry.flush();
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* The `LUNORA_TRACEPARENT` env fallback fits a one-shot container that
|
|
85
|
+
* processes a single job per start (the value is fixed for the process).
|
|
86
|
+
*/
|
|
87
|
+
traceparent?: string;
|
|
88
|
+
}
|
|
89
|
+
/** The exporter handle {@link createContainerTelemetry} returns. */
|
|
90
|
+
interface ContainerTelemetry {
|
|
91
|
+
/** Record one log line (no-op when disabled). */
|
|
92
|
+
emitLog: (log: ContainerLogInput) => void;
|
|
93
|
+
/** Record one span (no-op when disabled). */
|
|
94
|
+
emitSpan: (span: ContainerSpanInput) => void;
|
|
95
|
+
/** True when an endpoint resolved and exports are actually sent. */
|
|
96
|
+
readonly enabled: boolean;
|
|
97
|
+
/** Await all in-flight sends — call before the process exits. */
|
|
98
|
+
flush: () => Promise<void>;
|
|
99
|
+
/** Time `run()`, recording a span named `name` (ok, or errored if it throws). Always runs `run()`, even when disabled. */
|
|
100
|
+
trace: <T>(name: string, run: () => Promise<T>, attributes?: Record<string, ContainerAttributeValue>) => Promise<T>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Create a zero-config OTLP exporter for the container process.
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* const telemetry = createContainerTelemetry(); // reads LUNORA_OTLP_ENDPOINT / _TOKEN
|
|
107
|
+
* await telemetry.trace("transcode", () => transcode(job), { jobId: job.id });
|
|
108
|
+
* telemetry.emitLog({ level: "info", message: "done", attributes: { jobId: job.id } });
|
|
109
|
+
* await telemetry.flush(); // before the process exits
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* With no endpoint resolvable the returned exporter is disabled (`enabled ===
|
|
113
|
+
* false`): `emitSpan`/`emitLog` no-op and `trace` still runs its work but records
|
|
114
|
+
* nothing — so the same code runs unchanged locally and in the cloud.
|
|
115
|
+
* @param options Exporter options; every field falls back to a `LUNORA_*` env var.
|
|
116
|
+
*/
|
|
117
|
+
declare const createContainerTelemetry: (options?: ContainerTelemetryOptions) => ContainerTelemetry;
|
|
118
|
+
export { type ContainerAttributeValue, type ContainerLogInput, type ContainerSpanInput, type ContainerTelemetry, type ContainerTelemetryOptions, type OtelFetchLike, createContainerTelemetry };
|
package/dist/otel.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const OTLP_SEVERITY = {
|
|
2
|
+
debug: 5,
|
|
3
|
+
// DEBUG
|
|
4
|
+
error: 17,
|
|
5
|
+
// ERROR
|
|
6
|
+
info: 9,
|
|
7
|
+
// INFO
|
|
8
|
+
log: 9,
|
|
9
|
+
// INFO
|
|
10
|
+
warn: 13
|
|
11
|
+
// WARN
|
|
12
|
+
};
|
|
13
|
+
const otlpUnixNano = (ms) => `${String(Math.round(ms))}000000`;
|
|
14
|
+
const otlpRandomHex = (bytes) => {
|
|
15
|
+
const buffer = new Uint8Array(bytes);
|
|
16
|
+
crypto.getRandomValues(buffer);
|
|
17
|
+
let hex = "";
|
|
18
|
+
for (const byte of buffer) {
|
|
19
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
20
|
+
}
|
|
21
|
+
return hex;
|
|
22
|
+
};
|
|
23
|
+
const HEX_ONLY = /^[0-9a-f]+$/;
|
|
24
|
+
const parseTraceparent = (header) => {
|
|
25
|
+
if (header === null || header === void 0) {
|
|
26
|
+
return void 0;
|
|
27
|
+
}
|
|
28
|
+
const parts = header.trim().toLowerCase().split("-");
|
|
29
|
+
const [version, traceId, parentSpanId, flags] = parts;
|
|
30
|
+
if (parts.length < 4 || version === void 0 || version.length !== 2 || !HEX_ONLY.test(version) || version === "ff" || // Version 00 forbids trailing fields; only a future version may carry them.
|
|
31
|
+
version === "00" && parts.length !== 4 || traceId === void 0 || parentSpanId === void 0 || flags === void 0 || flags.length !== 2 || !HEX_ONLY.test(flags) || traceId.length !== 32 || parentSpanId.length !== 16 || !HEX_ONLY.test(traceId) || !HEX_ONLY.test(parentSpanId) || traceId === "00000000000000000000000000000000" || parentSpanId === "0000000000000000") {
|
|
32
|
+
return void 0;
|
|
33
|
+
}
|
|
34
|
+
return { parentSpanId, traceId };
|
|
35
|
+
};
|
|
36
|
+
const encodeAttribute = (key, value) => {
|
|
37
|
+
if (typeof value === "boolean") {
|
|
38
|
+
return { key, value: { boolValue: value } };
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === "number") {
|
|
41
|
+
if (!Number.isFinite(value)) {
|
|
42
|
+
return { key, value: { stringValue: String(value) } };
|
|
43
|
+
}
|
|
44
|
+
return Number.isSafeInteger(value) ? { key, value: { intValue: String(value) } } : { key, value: { doubleValue: value } };
|
|
45
|
+
}
|
|
46
|
+
return { key, value: { stringValue: value } };
|
|
47
|
+
};
|
|
48
|
+
const encodeAttributes = (attributes) => {
|
|
49
|
+
if (attributes === void 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return Object.entries(attributes).map(([key, value]) => encodeAttribute(key, value));
|
|
53
|
+
};
|
|
54
|
+
const mergeHeaders = (defaults, overrides, token) => {
|
|
55
|
+
const merged = {};
|
|
56
|
+
const seen = /* @__PURE__ */ new Map();
|
|
57
|
+
const put = (name, value) => {
|
|
58
|
+
const lower = name.toLowerCase();
|
|
59
|
+
const existing = seen.get(lower);
|
|
60
|
+
if (existing === void 0) {
|
|
61
|
+
seen.set(lower, name);
|
|
62
|
+
merged[name] = value;
|
|
63
|
+
} else {
|
|
64
|
+
merged[existing] = value;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
for (const [name, value] of Object.entries(defaults)) {
|
|
68
|
+
put(name, value);
|
|
69
|
+
}
|
|
70
|
+
for (const [name, value] of Object.entries(overrides ?? {})) {
|
|
71
|
+
put(name, value);
|
|
72
|
+
}
|
|
73
|
+
if (token !== void 0 && token.length > 0) {
|
|
74
|
+
put("authorization", `Bearer ${token}`);
|
|
75
|
+
}
|
|
76
|
+
return merged;
|
|
77
|
+
};
|
|
78
|
+
const wrapResourceSpans = (span, scopeName, serviceName) => {
|
|
79
|
+
return {
|
|
80
|
+
resourceSpans: [
|
|
81
|
+
{
|
|
82
|
+
resource: { attributes: [encodeAttribute("service.name", serviceName)] },
|
|
83
|
+
scopeSpans: [{ scope: { name: scopeName }, spans: [span] }]
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
const wrapResourceLogs = (logRecord, scopeName, serviceName) => {
|
|
89
|
+
return {
|
|
90
|
+
resourceLogs: [
|
|
91
|
+
{
|
|
92
|
+
resource: { attributes: [encodeAttribute("service.name", serviceName)] },
|
|
93
|
+
scopeLogs: [{ logRecords: [logRecord], scope: { name: scopeName } }]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
100
|
+
const readEnv = (name) => {
|
|
101
|
+
return process.env[name];
|
|
102
|
+
};
|
|
103
|
+
const resolveFetch = (injected) => {
|
|
104
|
+
if (injected !== void 0) {
|
|
105
|
+
return injected;
|
|
106
|
+
}
|
|
107
|
+
if (typeof globalThis.fetch === "function") {
|
|
108
|
+
return globalThis.fetch;
|
|
109
|
+
}
|
|
110
|
+
return void 0;
|
|
111
|
+
};
|
|
112
|
+
const traceBody = (span, serviceName, parent) => {
|
|
113
|
+
const attributes = encodeAttributes(span.attributes);
|
|
114
|
+
if (span.error?.type !== void 0) {
|
|
115
|
+
attributes.push(encodeAttribute("error.type", span.error.type));
|
|
116
|
+
}
|
|
117
|
+
const otlpSpan = {
|
|
118
|
+
attributes,
|
|
119
|
+
endTimeUnixNano: otlpUnixNano(span.endMs),
|
|
120
|
+
// SPAN_KIND_INTERNAL — the container's own work, not a server/client edge.
|
|
121
|
+
kind: 1,
|
|
122
|
+
name: span.name,
|
|
123
|
+
// Always the span's own (child) id; with a parent, hang it off the parent
|
|
124
|
+
// span and inherit the parent's trace id so the spans stitch into one trace.
|
|
125
|
+
...parent === void 0 ? {} : { parentSpanId: parent.parentSpanId },
|
|
126
|
+
spanId: otlpRandomHex(8),
|
|
127
|
+
startTimeUnixNano: otlpUnixNano(span.startMs),
|
|
128
|
+
// STATUS_CODE_OK (1) / STATUS_CODE_ERROR (2).
|
|
129
|
+
status: span.error === void 0 ? { code: 1 } : { code: 2, message: span.error.message },
|
|
130
|
+
traceId: parent?.traceId ?? otlpRandomHex(16)
|
|
131
|
+
};
|
|
132
|
+
return wrapResourceSpans(otlpSpan, "@lunora/container", serviceName);
|
|
133
|
+
};
|
|
134
|
+
const logBody = (log, serviceName, nowMs) => {
|
|
135
|
+
const level = log.level ?? "info";
|
|
136
|
+
const record = {
|
|
137
|
+
attributes: encodeAttributes(log.attributes),
|
|
138
|
+
body: { stringValue: log.message },
|
|
139
|
+
severityNumber: OTLP_SEVERITY[level],
|
|
140
|
+
severityText: level.toUpperCase(),
|
|
141
|
+
timeUnixNano: otlpUnixNano(log.ts ?? nowMs)
|
|
142
|
+
};
|
|
143
|
+
return wrapResourceLogs(record, "@lunora/container", serviceName);
|
|
144
|
+
};
|
|
145
|
+
const createContainerTelemetry = (options = {}) => {
|
|
146
|
+
const endpoint = options.endpoint ?? readEnv("LUNORA_OTLP_ENDPOINT");
|
|
147
|
+
const enabled = endpoint !== void 0 && endpoint.length > 0;
|
|
148
|
+
const token = options.token ?? readEnv("LUNORA_OTLP_TOKEN");
|
|
149
|
+
const serviceName = options.serviceName ?? readEnv("LUNORA_SERVICE_NAME") ?? "lunora-container";
|
|
150
|
+
const parent = parseTraceparent(options.traceparent ?? readEnv("LUNORA_TRACEPARENT"));
|
|
151
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
152
|
+
const fetchImpl = resolveFetch(options.fetch);
|
|
153
|
+
const headers = mergeHeaders({ "content-type": "application/json" }, options.headers, token);
|
|
154
|
+
let base = endpoint ?? "";
|
|
155
|
+
while (base.endsWith("/")) {
|
|
156
|
+
base = base.slice(0, -1);
|
|
157
|
+
}
|
|
158
|
+
const tracesUrl = `${base}/v1/traces`;
|
|
159
|
+
const logsUrl = `${base}/v1/logs`;
|
|
160
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
161
|
+
const send = (url, body) => {
|
|
162
|
+
if (fetchImpl === void 0) {
|
|
163
|
+
options.onError?.(new TypeError("createContainerTelemetry: no `fetch` available — pass `fetch` in options for this runtime."));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const dispatch = async () => {
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetchImpl(url, { body: JSON.stringify(body), headers, method: "POST", signal: AbortSignal.timeout(timeoutMs) });
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
options.onError?.(new Error(`createContainerTelemetry: OTLP export to ${url} failed with status ${String(response.status)}.`));
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await response.body?.cancel();
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
options.onError?.(error);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const settled = dispatch().finally(() => {
|
|
181
|
+
inflight.delete(settled);
|
|
182
|
+
});
|
|
183
|
+
inflight.add(settled);
|
|
184
|
+
};
|
|
185
|
+
const emitSpan = (span) => {
|
|
186
|
+
if (!enabled) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
send(tracesUrl, traceBody(span, serviceName, parent));
|
|
190
|
+
};
|
|
191
|
+
const emitLog = (log) => {
|
|
192
|
+
if (!enabled) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
send(logsUrl, logBody(log, serviceName, Date.now()));
|
|
196
|
+
};
|
|
197
|
+
const trace = async (name, run, attributes) => {
|
|
198
|
+
const startMs = Date.now();
|
|
199
|
+
try {
|
|
200
|
+
const result = await run();
|
|
201
|
+
emitSpan({ attributes, endMs: Date.now(), name, startMs });
|
|
202
|
+
return result;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
emitSpan({
|
|
205
|
+
attributes,
|
|
206
|
+
endMs: Date.now(),
|
|
207
|
+
error: { message: error instanceof Error ? error.message : String(error), type: error instanceof Error ? error.name : void 0 },
|
|
208
|
+
name,
|
|
209
|
+
startMs
|
|
210
|
+
});
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const flush = async () => {
|
|
215
|
+
await Promise.allSettled(inflight);
|
|
216
|
+
};
|
|
217
|
+
return { emitLog, emitSpan, enabled, flush, trace };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export { createContainerTelemetry };
|