@visiolab/effect-firebase-functions 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +228 -2
- package/dist/Functions.d.mts +339 -0
- package/dist/Functions.mjs +213 -0
- package/dist/Functions.mjs.map +1 -0
- package/dist/_virtual/_rolldown/runtime.mjs +12 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/internal/callable.mjs +104 -0
- package/dist/internal/callable.mjs.map +1 -0
- package/dist/internal/documentTrigger.mjs +110 -0
- package/dist/internal/documentTrigger.mjs.map +1 -0
- package/dist/internal/kinds.mjs +19 -0
- package/dist/internal/kinds.mjs.map +1 -0
- package/dist/internal/member.mjs +12 -0
- package/dist/internal/member.mjs.map +1 -0
- package/dist/internal/runtime.mjs +79 -0
- package/dist/internal/runtime.mjs.map +1 -0
- package/dist/internal/serverSpan.mjs +187 -0
- package/dist/internal/serverSpan.mjs.map +1 -0
- package/dist/logger/CloudLogging.d.mts +92 -0
- package/dist/logger/CloudLogging.mjs +146 -0
- package/dist/logger/CloudLogging.mjs.map +1 -0
- package/dist/logger/index.d.mts +2 -0
- package/dist/logger/index.mjs +1 -0
- package/package.json +64 -3
- package/src/Functions.ts +482 -0
- package/src/index.ts +8 -0
- package/src/internal/callable.ts +156 -0
- package/src/internal/documentTrigger.ts +188 -0
- package/src/internal/kinds.ts +36 -0
- package/src/internal/member.ts +21 -0
- package/src/internal/runtime.ts +110 -0
- package/src/internal/serverSpan.ts +203 -0
- package/src/logger/CloudLogging.ts +152 -0
- package/src/logger/index.ts +8 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Document Trigger kind's runtime: serves one Document Trigger as an
|
|
3
|
+
* `onDocument*` export.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
import { Cause, Effect, Exit, type Layer, Option } from "effect";
|
|
8
|
+
import * as AdminFirestoreValue from "@visiolab/effect-firebase-admin/firestore/AdminFirestoreValue";
|
|
9
|
+
import * as Collection from "@visiolab/effect-firebase/firestore/Collection";
|
|
10
|
+
import * as DocumentSnapshot from "@visiolab/effect-firebase/firestore/DocumentSnapshot";
|
|
11
|
+
import * as DocumentTrigger from "@visiolab/effect-firebase/functions/DocumentTrigger";
|
|
12
|
+
import type * as FunctionGroup from "@visiolab/effect-firebase/functions/FunctionGroup";
|
|
13
|
+
import * as Firestore from "firebase-functions/v2/firestore";
|
|
14
|
+
import * as Member from "./member.ts";
|
|
15
|
+
import * as Runtime from "./runtime.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The typed reference of the document an event is about, from its path
|
|
19
|
+
* relative to the database's `documents/`. A path outside the trigger's
|
|
20
|
+
* collection can't come from its own path pattern, so it is a defect.
|
|
21
|
+
*/
|
|
22
|
+
const refOf = (trigger: DocumentTrigger.Any, path: string) =>
|
|
23
|
+
Option.match(Collection.fromPath(trigger.collection, path), {
|
|
24
|
+
onNone: () =>
|
|
25
|
+
Effect.die(
|
|
26
|
+
new Error(
|
|
27
|
+
`The document "${path}" is not in the collection of the trigger "${trigger.name}"`,
|
|
28
|
+
),
|
|
29
|
+
),
|
|
30
|
+
onSome: Effect.succeed,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An existing Document Snapshot at `ref` of the Admin SDK's snapshot, its
|
|
35
|
+
* data read into Firestore Values as a read does. Data that isn't a Firestore
|
|
36
|
+
* Value, such as a vector, is a defect.
|
|
37
|
+
*/
|
|
38
|
+
const existingOf = (
|
|
39
|
+
ref: Collection.DocumentReference<Collection.Any>,
|
|
40
|
+
snapshot: Firestore.DocumentSnapshot | undefined,
|
|
41
|
+
): Effect.Effect<DocumentSnapshot.Existing<Collection.Any>> =>
|
|
42
|
+
snapshot === undefined
|
|
43
|
+
? Effect.die(new Error(`The event of "${ref.path}" carries no document`))
|
|
44
|
+
: Effect.map(
|
|
45
|
+
Effect.orDie(Effect.fromResult(AdminFirestoreValue.fromAdmin(snapshot.data()))),
|
|
46
|
+
(raw) => DocumentSnapshot.existing(ref, raw),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The document of an event that hands over one, as an existing Document
|
|
51
|
+
* Snapshot: the created one, or the deleted one as it was just before.
|
|
52
|
+
*/
|
|
53
|
+
const snapshotOf = (
|
|
54
|
+
ref: Collection.DocumentReference<Collection.Any>,
|
|
55
|
+
snapshot: Firestore.QueryDocumentSnapshot | undefined,
|
|
56
|
+
) => Effect.map(existingOf(ref, snapshot), (snapshot) => ({ snapshot }));
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A Document Snapshot at `ref` of a side of a write: `Missing` for the side
|
|
60
|
+
* that doesn't exist, as the Admin SDK marks it with `exists`.
|
|
61
|
+
*/
|
|
62
|
+
const sideOf = (
|
|
63
|
+
ref: Collection.DocumentReference<Collection.Any>,
|
|
64
|
+
snapshot: Firestore.DocumentSnapshot,
|
|
65
|
+
): Effect.Effect<DocumentSnapshot.DocumentSnapshot<Collection.Any>> =>
|
|
66
|
+
snapshot.exists ? existingOf(ref, snapshot) : Effect.succeed(DocumentSnapshot.missing(ref));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Both sides of a change, each read with `side`: both existing for an update,
|
|
70
|
+
* and either one missing for a write. An event without its change is a defect.
|
|
71
|
+
*/
|
|
72
|
+
const changeOf =
|
|
73
|
+
<S, A>(
|
|
74
|
+
side: (ref: Collection.DocumentReference<Collection.Any>, snapshot: S) => Effect.Effect<A>,
|
|
75
|
+
) =>
|
|
76
|
+
(ref: Collection.DocumentReference<Collection.Any>, change: Firestore.Change<S> | undefined) =>
|
|
77
|
+
change === undefined
|
|
78
|
+
? Effect.die(new Error(`The event of "${ref.path}" carries no change`))
|
|
79
|
+
: Effect.all({ before: side(ref, change.before), after: side(ref, change.after) });
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The conversion of what an `onDocument*` provider hands over into the core
|
|
83
|
+
* event of a trigger: `read` reads the event's data at the document's typed
|
|
84
|
+
* reference, and `make` builds the core event from that and the CloudEvent's
|
|
85
|
+
* `id` and `time`.
|
|
86
|
+
*/
|
|
87
|
+
const conversion =
|
|
88
|
+
<Data, Fields>(
|
|
89
|
+
read: (ref: Collection.DocumentReference<Collection.Any>, data: Data) => Effect.Effect<Fields>,
|
|
90
|
+
make: (input: Fields & { readonly id: string; readonly time: string }) => unknown,
|
|
91
|
+
) =>
|
|
92
|
+
(trigger: DocumentTrigger.Any, event: Firestore.FirestoreEvent<Data>) =>
|
|
93
|
+
Effect.flatMap(refOf(trigger, event.document), (ref) =>
|
|
94
|
+
Effect.map(read(ref, event.data), (fields) =>
|
|
95
|
+
make({ ...fields, id: event.id, time: event.time }),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const createdEvent = conversion(snapshotOf, DocumentTrigger.createdEvent);
|
|
100
|
+
const deletedEvent = conversion(snapshotOf, DocumentTrigger.deletedEvent);
|
|
101
|
+
const updatedEvent = conversion(changeOf(existingOf), DocumentTrigger.updatedEvent);
|
|
102
|
+
const writtenEvent = conversion(changeOf(sideOf), DocumentTrigger.writtenEvent);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Logs the Cause of a failed event or Layer build in the event's span.
|
|
106
|
+
*/
|
|
107
|
+
const logFailure = (message: string) => (cause: Cause.Cause<unknown>) =>
|
|
108
|
+
Effect.logError(message, cause);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `DocumentTrigger.serve` of one trigger, with its requirements erased, since
|
|
112
|
+
* the public signatures check that `live` meets them.
|
|
113
|
+
*/
|
|
114
|
+
type Serve = Effect.Effect<(event: unknown) => Effect.Effect<void, unknown>, never, unknown>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What an `onDocument*` provider returns, by the change it runs on.
|
|
118
|
+
*
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
export type Export =
|
|
122
|
+
| ReturnType<typeof Firestore.onDocumentCreated>
|
|
123
|
+
| ReturnType<typeof Firestore.onDocumentUpdated>
|
|
124
|
+
| ReturnType<typeof Firestore.onDocumentWritten>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The export of `trigger` in `group`, or in no group: the `onDocument*`
|
|
128
|
+
* export of its change (`onDocumentCreated`, `onDocumentDeleted`,
|
|
129
|
+
* `onDocumentUpdated` or `onDocumentWritten`) on the `(default)` database, at
|
|
130
|
+
* the path pattern of the trigger's collection, that converts each event and
|
|
131
|
+
* runs it through `DocumentTrigger.serve`. It builds `live` on its own first
|
|
132
|
+
* event. A failed event, or a failed build, is logged and thrown, so Firebase
|
|
133
|
+
* delivers it again when `retry` is set.
|
|
134
|
+
*
|
|
135
|
+
* @internal
|
|
136
|
+
*/
|
|
137
|
+
export const deploy = <R, E>(
|
|
138
|
+
group: FunctionGroup.Any | undefined,
|
|
139
|
+
trigger: DocumentTrigger.Any,
|
|
140
|
+
live: Layer.Layer<R, E>,
|
|
141
|
+
options: object = {},
|
|
142
|
+
): Export => {
|
|
143
|
+
const deployment = Member.call(group, trigger, DocumentTrigger.deployment);
|
|
144
|
+
const serve = Member.call<Serve>(group, trigger, DocumentTrigger.serve);
|
|
145
|
+
const run = Runtime.make(live, {
|
|
146
|
+
span: `DocumentTrigger.${deployment.name}`,
|
|
147
|
+
attributes: { "faas.name": deployment.name, "faas.trigger": "datasource" },
|
|
148
|
+
onBuildFailure: logFailure("The Layer of a Document Trigger failed to build"),
|
|
149
|
+
});
|
|
150
|
+
/** The raw handler that converts each event with `convert` and serves it. */
|
|
151
|
+
const handler =
|
|
152
|
+
<Data>(
|
|
153
|
+
convert: (
|
|
154
|
+
trigger: DocumentTrigger.Any,
|
|
155
|
+
event: Firestore.FirestoreEvent<Data>,
|
|
156
|
+
) => Effect.Effect<unknown>,
|
|
157
|
+
) =>
|
|
158
|
+
async (event: Firestore.FirestoreEvent<Data>) => {
|
|
159
|
+
const exit = await run(
|
|
160
|
+
serve.pipe(
|
|
161
|
+
Effect.flatMap((handle) =>
|
|
162
|
+
Effect.flatMap(convert(trigger, event), (input) => handle(input)),
|
|
163
|
+
),
|
|
164
|
+
// Logged with the services of `live`, so a logger it provides is used.
|
|
165
|
+
Effect.onError(logFailure("A Document Trigger failed")),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
if (Exit.isFailure(exit)) throw Cause.squash(exit.cause);
|
|
169
|
+
};
|
|
170
|
+
// After `options`, so a caller that passes event filters past the public
|
|
171
|
+
// types can't move the trigger to another path, database or namespace.
|
|
172
|
+
const providerOptions = {
|
|
173
|
+
...options,
|
|
174
|
+
document: deployment.document,
|
|
175
|
+
database: "(default)",
|
|
176
|
+
namespace: "(default)",
|
|
177
|
+
};
|
|
178
|
+
switch (trigger.event) {
|
|
179
|
+
case "created":
|
|
180
|
+
return Firestore.onDocumentCreated(providerOptions, handler(createdEvent));
|
|
181
|
+
case "deleted":
|
|
182
|
+
return Firestore.onDocumentDeleted(providerOptions, handler(deletedEvent));
|
|
183
|
+
case "updated":
|
|
184
|
+
return Firestore.onDocumentUpdated(providerOptions, handler(updatedEvent));
|
|
185
|
+
case "written":
|
|
186
|
+
return Firestore.onDocumentWritten(providerOptions, handler(writtenEvent));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `Functions.make` and `Functions.group` dispatch on a Function's kind.
|
|
3
|
+
* A new kind adds its runtime's `deploy` here, and its types to the
|
|
4
|
+
* kind-dispatched types of `Functions.ts` (`Export`, `Options`, `Services`).
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import type { Layer } from "effect";
|
|
9
|
+
import type * as Callable from "@visiolab/effect-firebase/functions/Callable";
|
|
10
|
+
import type * as DocumentTrigger from "@visiolab/effect-firebase/functions/DocumentTrigger";
|
|
11
|
+
import type * as FirebaseFunction from "@visiolab/effect-firebase/functions/FirebaseFunction";
|
|
12
|
+
import type * as FunctionGroup from "@visiolab/effect-firebase/functions/FunctionGroup";
|
|
13
|
+
import * as callable from "./callable.ts";
|
|
14
|
+
import * as documentTrigger from "./documentTrigger.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The export of `fn` in `group`, or in no group, from its kind's runtime.
|
|
18
|
+
* `options` are the member's own, typed by kind in the public signatures.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export const deploy = <R, E>(
|
|
23
|
+
group: FunctionGroup.Any | undefined,
|
|
24
|
+
fn: FirebaseFunction.Any,
|
|
25
|
+
live: Layer.Layer<R, E>,
|
|
26
|
+
options: object | undefined,
|
|
27
|
+
): unknown => {
|
|
28
|
+
switch (fn.kind) {
|
|
29
|
+
case "Callable":
|
|
30
|
+
return callable.deploy(group, fn as Callable.Any, live, options);
|
|
31
|
+
case "DocumentTrigger":
|
|
32
|
+
return documentTrigger.deploy(group, fn as DocumentTrigger.Any, live, options);
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`Functions can't deploy "${fn.name}": its kind "${fn.kind}" is unknown`);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a kind's runtime calls the core functions that take a Function alone or
|
|
3
|
+
* a Function Group and a member's name, such as `deployment` and `serve`.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
import type * as FirebaseFunction from "@visiolab/effect-firebase/functions/FirebaseFunction";
|
|
8
|
+
import type * as FunctionGroup from "@visiolab/effect-firebase/functions/FunctionGroup";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calls `api` with `fn` alone, or with `group` and the name of `fn` when it is
|
|
12
|
+
* a member of `group`. The kind's public signatures already checked that `fn`
|
|
13
|
+
* is of the kind `api` takes, so its arguments are erased.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export const call = <A>(
|
|
18
|
+
group: FunctionGroup.Any | undefined,
|
|
19
|
+
fn: FirebaseFunction.Any,
|
|
20
|
+
api: (...args: ReadonlyArray<never>) => A,
|
|
21
|
+
): A => (group === undefined ? api(fn as never) : api(group as never, fn.name as never));
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every kind of Function runs an event or a call under: the lazy,
|
|
3
|
+
* memoized build of its Layer, its server span, its Tracer and its logs.
|
|
4
|
+
*
|
|
5
|
+
* A kind's runtime turns what `firebase-functions` hands over into the input
|
|
6
|
+
* of its core `serve`, and passes that to {@link make}'s run function, which
|
|
7
|
+
* does the rest.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
import { type Cause, Context, Effect, Exit, Layer, Logger, Scope } from "effect";
|
|
12
|
+
import * as CloudLogging from "../logger/CloudLogging.ts";
|
|
13
|
+
import { DeferredTracer, externalParent } from "./serverSpan.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The memo map and scope every Layer of this process is built in, so a Layer
|
|
17
|
+
* that several exports share is built once. The scope is never closed:
|
|
18
|
+
* functions-framework exits the process without waiting for finalizers.
|
|
19
|
+
*/
|
|
20
|
+
const memoMap = Layer.makeMemoMapUnsafe();
|
|
21
|
+
const scope = Scope.makeUnsafe();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Builds `live` on the first event, and hands every later event the same
|
|
25
|
+
* services. Concurrent first events share one build. The build runs under the
|
|
26
|
+
* span and Tracer of the event that starts it, so it nests under that event.
|
|
27
|
+
*
|
|
28
|
+
* A failed build fails the event and is tried again on the next one. A Layer
|
|
29
|
+
* that fails closes the scope it was built in, which drops its failed entries
|
|
30
|
+
* from the memo map, so the retry builds anew.
|
|
31
|
+
*/
|
|
32
|
+
const lazyContext = <R, E>(live: Layer.Layer<R, E>) => {
|
|
33
|
+
let pending: Promise<Exit.Exit<Context.Context<R>, E>> | undefined;
|
|
34
|
+
return Effect.flatMap(Effect.context<never>(), (caller) => {
|
|
35
|
+
pending ??= Effect.runPromiseExit(
|
|
36
|
+
Layer.buildWithMemoMap(live, memoMap, scope).pipe(Effect.provideContext(caller)),
|
|
37
|
+
).then((exit) => {
|
|
38
|
+
if (Exit.isFailure(exit)) pending = undefined;
|
|
39
|
+
return exit;
|
|
40
|
+
});
|
|
41
|
+
const attempt = pending;
|
|
42
|
+
return Effect.flatten(Effect.promise(() => attempt));
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How one export runs each event or call.
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export interface Options {
|
|
52
|
+
/** The name of the `kind: "server"` span each event or call runs in. */
|
|
53
|
+
readonly span: string;
|
|
54
|
+
/** The attributes of that span. */
|
|
55
|
+
readonly attributes: Readonly<Record<string, unknown>>;
|
|
56
|
+
/**
|
|
57
|
+
* Logs the Cause of a Layer that failed to build, in the span of the event
|
|
58
|
+
* or call that built it.
|
|
59
|
+
*/
|
|
60
|
+
readonly onBuildFailure: (cause: Cause.Cause<unknown>) => Effect.Effect<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The run function of one export: it builds `live` on the export's first
|
|
65
|
+
* event or call, so a worker that serves one export builds nothing for the
|
|
66
|
+
* others, and runs each `body` with the services of `live` provided.
|
|
67
|
+
*
|
|
68
|
+
* The run happens in a `kind: "server"` span, parented on the trace context
|
|
69
|
+
* Firebase read from the event or request. Its spans go to the Tracer of
|
|
70
|
+
* `live`, its logs go through `CloudLogging.layer` unless `live` provides a
|
|
71
|
+
* logger, and logs from before `live` is built wait until the spans have
|
|
72
|
+
* their final ids.
|
|
73
|
+
*
|
|
74
|
+
* Read the trace context synchronously, at the start of the SDK's handler:
|
|
75
|
+
* call the run function there.
|
|
76
|
+
*
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
export const make = <R, E>(live: Layer.Layer<R, E>, options: Options) => {
|
|
80
|
+
const context = lazyContext(live);
|
|
81
|
+
const spanOptions = { kind: "server", attributes: options.attributes } as const;
|
|
82
|
+
return <A>(
|
|
83
|
+
body: Effect.Effect<A, unknown, unknown>,
|
|
84
|
+
signal?: AbortSignal,
|
|
85
|
+
): Promise<Exit.Exit<A, unknown>> => {
|
|
86
|
+
const parent = externalParent();
|
|
87
|
+
const deferred = new DeferredTracer();
|
|
88
|
+
return Effect.runPromiseExit(
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const services = yield* context.pipe(
|
|
91
|
+
// With no services to take a Tracer from, the run's spans and held
|
|
92
|
+
// logs go to the native Tracer, so the failure is logged in one trace.
|
|
93
|
+
Effect.onError(() => Effect.sync(() => deferred.attach(Context.empty()))),
|
|
94
|
+
Effect.onError(options.onBuildFailure),
|
|
95
|
+
);
|
|
96
|
+
deferred.attach(services as Context.Context<never>);
|
|
97
|
+
// The public signatures check that `live` provides what `body` needs.
|
|
98
|
+
return yield* Effect.provideContext(body, services as Context.Context<any>);
|
|
99
|
+
}).pipe(
|
|
100
|
+
Effect.withSpan(options.span, { ...spanOptions, parent }),
|
|
101
|
+
// Logs from before `live` is built wait for the spans' real ids.
|
|
102
|
+
Effect.updateService(Logger.CurrentLoggers, (loggers) => deferred.holdLogs(loggers)),
|
|
103
|
+
// Beneath `live`: a logger that `live` provides replaces this one.
|
|
104
|
+
Effect.provide(CloudLogging.layer),
|
|
105
|
+
Effect.withTracer(deferred.tracer),
|
|
106
|
+
),
|
|
107
|
+
signal === undefined ? undefined : { signal },
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
import { Context, Logger, Option, Tracer, type Exit, type Fiber } from "effect";
|
|
3
|
+
import { traceContext } from "firebase-functions/v2";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The parent of a call's server span: the trace context `firebase-functions`
|
|
7
|
+
* read from the request's headers, the `X-Cloud-Trace-Context` header winning
|
|
8
|
+
* over `traceparent`. Read it synchronously at the start of the call, before
|
|
9
|
+
* any fiber runs: Effect's scheduler may later run under another request's
|
|
10
|
+
* async store. `undefined` when the request carried none, as in the emulator.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export const externalParent = (): Tracer.ExternalSpan | undefined => {
|
|
15
|
+
const store = traceContext.getStore();
|
|
16
|
+
if (store === undefined) return undefined;
|
|
17
|
+
return Tracer.externalSpan({
|
|
18
|
+
traceId: store.traceId,
|
|
19
|
+
// The Cloud header's decimal span id reaches us as unpadded hex.
|
|
20
|
+
spanId: store.parentId.padStart(16, "0"),
|
|
21
|
+
sampled: store.sample,
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SpanOptions = Parameters<Tracer.Tracer["span"]>[0];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A span opened before the Tracer of `Live` is known. It records what happens
|
|
29
|
+
* to it, and replays that onto the real span once {@link DeferredTracer.attach}
|
|
30
|
+
* creates it. From then on it forwards everything.
|
|
31
|
+
*/
|
|
32
|
+
class DeferredSpan implements Tracer.Span {
|
|
33
|
+
readonly _tag = "Span";
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly parent: Option.Option<Tracer.AnySpan>;
|
|
36
|
+
readonly annotations: Context.Context<never>;
|
|
37
|
+
readonly kind: Tracer.SpanKind;
|
|
38
|
+
readonly sampled: boolean;
|
|
39
|
+
readonly options: SpanOptions;
|
|
40
|
+
real: Tracer.Span | undefined;
|
|
41
|
+
// What happened before `real` existed, in order.
|
|
42
|
+
private readonly calls: Array<(span: Tracer.Span) => void> = [];
|
|
43
|
+
private readonly pendingAttributes = new Map<string, unknown>();
|
|
44
|
+
private readonly pendingLinks: Array<Tracer.SpanLink>;
|
|
45
|
+
private pendingStatus: Tracer.SpanStatus;
|
|
46
|
+
private provisional: { traceId: string; spanId: string } | undefined;
|
|
47
|
+
|
|
48
|
+
constructor(options: SpanOptions) {
|
|
49
|
+
this.options = options;
|
|
50
|
+
this.name = options.name;
|
|
51
|
+
this.parent = options.parent;
|
|
52
|
+
this.annotations = options.annotations;
|
|
53
|
+
this.kind = options.kind;
|
|
54
|
+
this.sampled = options.sampled;
|
|
55
|
+
this.pendingLinks = [...options.links];
|
|
56
|
+
this.pendingStatus = { _tag: "Started", startTime: options.startTime };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Read before `attach` only by a Tracer that `Live` provides to its own
|
|
60
|
+
// Layers: such a span joins the trace, but its parent's span id is one no
|
|
61
|
+
// exporter sees.
|
|
62
|
+
private get ids() {
|
|
63
|
+
if (this.provisional === undefined) {
|
|
64
|
+
const native = new Tracer.NativeSpan({ ...this.options, links: [] });
|
|
65
|
+
this.provisional = { traceId: native.traceId, spanId: native.spanId };
|
|
66
|
+
}
|
|
67
|
+
return this.provisional;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get traceId() {
|
|
71
|
+
return this.real?.traceId ?? this.ids.traceId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get spanId() {
|
|
75
|
+
return this.real?.spanId ?? this.ids.spanId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get status() {
|
|
79
|
+
return this.real?.status ?? this.pendingStatus;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get attributes() {
|
|
83
|
+
return this.real?.attributes ?? this.pendingAttributes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get links() {
|
|
87
|
+
return this.real?.links ?? this.pendingLinks;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
end(endTime: bigint, exit: Exit.Exit<unknown, unknown>) {
|
|
91
|
+
if (this.real !== undefined) return this.real.end(endTime, exit);
|
|
92
|
+
if (this.pendingStatus._tag === "Ended") return;
|
|
93
|
+
this.pendingStatus = { _tag: "Ended", startTime: this.pendingStatus.startTime, endTime, exit };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
attribute(key: string, value: unknown) {
|
|
97
|
+
if (this.real !== undefined) return this.real.attribute(key, value);
|
|
98
|
+
this.pendingAttributes.set(key, value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
event(name: string, startTime: bigint, attributes?: Record<string, unknown>) {
|
|
102
|
+
if (this.real !== undefined) return this.real.event(name, startTime, attributes);
|
|
103
|
+
this.calls.push((span) => span.event(name, startTime, attributes));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
addLinks(links: ReadonlyArray<Tracer.SpanLink>) {
|
|
107
|
+
if (this.real !== undefined) return this.real.addLinks(links);
|
|
108
|
+
this.calls.push((span) => span.addLinks(links));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Opens the real span on `tracer` and replays onto it what happened so far. */
|
|
112
|
+
attach(tracer: Tracer.Tracer) {
|
|
113
|
+
const parent = Option.map(this.parent, (parent) =>
|
|
114
|
+
parent instanceof DeferredSpan ? (parent.real ?? parent) : parent,
|
|
115
|
+
);
|
|
116
|
+
const real = tracer.span({ ...this.options, parent, links: this.pendingLinks });
|
|
117
|
+
for (const [key, value] of this.pendingAttributes) real.attribute(key, value);
|
|
118
|
+
for (const call of this.calls) call(real);
|
|
119
|
+
if (this.pendingStatus._tag === "Ended") {
|
|
120
|
+
real.end(this.pendingStatus.endTime, this.pendingStatus.exit);
|
|
121
|
+
}
|
|
122
|
+
this.real = real;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The Tracer a call starts under. A call's server span opens before `Live` is
|
|
128
|
+
* built, and so before its Tracer is known, so that a first call's Layer
|
|
129
|
+
* build nests under it. This Tracer defers the spans opened until then, and
|
|
130
|
+
* {@link DeferredTracer.attach} hands them to the Tracer of `Live`, which
|
|
131
|
+
* exports them with their original times and parents.
|
|
132
|
+
*
|
|
133
|
+
* @internal
|
|
134
|
+
*/
|
|
135
|
+
export class DeferredTracer {
|
|
136
|
+
private readonly deferred: Array<DeferredSpan> = [];
|
|
137
|
+
private readonly held: Array<() => void> = [];
|
|
138
|
+
private real: Tracer.Tracer | undefined;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Wraps each of `loggers` so that an entry logged before {@link attach}
|
|
142
|
+
* is held, and logged on `attach`, once the spans it was logged under have
|
|
143
|
+
* their real ids. A logger reads the trace of the current span, and a
|
|
144
|
+
* deferred span's ids before `attach` are ones no exporter sees.
|
|
145
|
+
*
|
|
146
|
+
* The tracer logger isn't held: it writes onto the span itself, which a
|
|
147
|
+
* deferred span already replays.
|
|
148
|
+
*/
|
|
149
|
+
holdLogs(
|
|
150
|
+
loggers: ReadonlySet<Logger.Logger<unknown, any>>,
|
|
151
|
+
): ReadonlySet<Logger.Logger<unknown, any>> {
|
|
152
|
+
return new Set(
|
|
153
|
+
Array.from(loggers, (logger) =>
|
|
154
|
+
logger === Logger.tracerLogger
|
|
155
|
+
? logger
|
|
156
|
+
: Logger.make<unknown, unknown>((options) => {
|
|
157
|
+
if (this.real !== undefined) return logger.log(options);
|
|
158
|
+
const held = { ...options, fiber: snapshot(options.fiber) };
|
|
159
|
+
this.held.push(() => logger.log(held));
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
readonly tracer: Tracer.Tracer = Tracer.make({
|
|
166
|
+
span: (options) => {
|
|
167
|
+
if (this.real !== undefined) return this.real.span(options);
|
|
168
|
+
const span = new DeferredSpan(options);
|
|
169
|
+
this.deferred.push(span);
|
|
170
|
+
return span;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Hands every span opened so far to the Tracer that `services` provide, the
|
|
176
|
+
* native one when they provide none, in the order they were opened, so that
|
|
177
|
+
* each parent exists before its children. Then logs the entries
|
|
178
|
+
* {@link holdLogs} held, in the order they were logged.
|
|
179
|
+
*
|
|
180
|
+
* Attach with no services when `Live` failed to build, so the spans and
|
|
181
|
+
* logs of the failed call still get ids of one trace.
|
|
182
|
+
*/
|
|
183
|
+
attach(services: Context.Context<never>) {
|
|
184
|
+
if (this.real !== undefined) return;
|
|
185
|
+
this.real = Context.get(services, Tracer.Tracer);
|
|
186
|
+
for (const span of this.deferred) span.attach(this.real);
|
|
187
|
+
this.deferred.length = 0;
|
|
188
|
+
for (const log of this.held) log();
|
|
189
|
+
this.held.length = 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `fiber` as it is now: what a logger reads from it later, such as the log
|
|
195
|
+
* annotations and the current span, stays what it was when the entry was
|
|
196
|
+
* logged. Both are derived from the fiber's context, which never changes in
|
|
197
|
+
* place, only gets replaced.
|
|
198
|
+
*/
|
|
199
|
+
const snapshot = <A, E>(fiber: Fiber.Fiber<A, E>): Fiber.Fiber<A, E> =>
|
|
200
|
+
Object.create(fiber, {
|
|
201
|
+
context: { value: fiber.context },
|
|
202
|
+
cache: { value: fiber.cache },
|
|
203
|
+
}) as Fiber.Fiber<A, E>;
|