@useairfoil/connector-kit 0.3.0 → 0.5.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/README.md +279 -90
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/connector-app/index.d.ts +9 -0
- package/dist/connector-app/index.js +8 -0
- package/dist/connector-app-CvqbfN6R.js +32 -0
- package/dist/connector-app-CvqbfN6R.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors-DHWylqRZ.js +8 -0
- package/dist/errors-DHWylqRZ.js.map +1 -0
- package/dist/index-8KnVpoZp.d.ts +32 -0
- package/dist/index-BlhJCpEk.d.ts +124 -0
- package/dist/index-CQJsFJs3.d.ts +23 -0
- package/dist/index-DK9YcR6W.d.ts +25 -0
- package/dist/index-DaNW63Mv.d.ts +21 -0
- package/dist/index-DyuT2jzP.d.ts +14 -0
- package/dist/index.d.ts +9 -166
- package/dist/index.js +44 -229
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +6 -0
- package/dist/ingestion/index.js +7 -0
- package/dist/ingestion-CMCroK7Y.js +171 -0
- package/dist/ingestion-CMCroK7Y.js.map +1 -0
- package/dist/publisher/index.d.ts +5 -0
- package/dist/publisher/index.js +5 -0
- package/dist/publisher-DusgYvaV.js +131 -0
- package/dist/publisher-DusgYvaV.js.map +1 -0
- package/dist/server-DAeFWT4o.js +113 -0
- package/dist/server-DAeFWT4o.js.map +1 -0
- package/dist/service-B7g_lHMI.d.ts +32 -0
- package/dist/state-store/index.d.ts +4 -0
- package/dist/state-store/index.js +3 -0
- package/dist/state-store-DG_4QpWG.js +31 -0
- package/dist/state-store-DG_4QpWG.js.map +1 -0
- package/dist/telemetry-p0dE5Mdu.js +171 -0
- package/dist/telemetry-p0dE5Mdu.js.map +1 -0
- package/dist/types-ZNrA-Dn0.d.ts +160 -0
- package/dist/webhook/index.d.ts +5 -0
- package/dist/webhook/index.js +6 -0
- package/dist/webhook-BSA-tXtU.js +13 -0
- package/dist/webhook-BSA-tXtU.js.map +1 -0
- package/package.json +41 -14
- package/src/connector-app/index.ts +1 -0
- package/src/connector-app/start.ts +38 -0
- package/src/core/builder.ts +73 -15
- package/src/core/index.ts +22 -0
- package/src/core/types.ts +186 -78
- package/src/errors/index.ts +1 -0
- package/src/index.ts +8 -28
- package/src/ingestion/engine.ts +302 -206
- package/src/ingestion/index.ts +1 -0
- package/src/publisher/console.ts +18 -0
- package/src/publisher/index.ts +7 -0
- package/src/publisher/service.ts +33 -14
- package/src/publisher/wings.ts +162 -82
- package/src/runtime/context.ts +15 -0
- package/src/state-store/index.ts +2 -0
- package/src/state-store/memory.ts +25 -0
- package/src/state-store/service.ts +18 -0
- package/src/telemetry.ts +246 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/server.ts +177 -54
- package/src/webhook/types.ts +6 -16
- package/src/ingestion/state-store.ts +0 -36
- package/src/streams/pull-stream.ts +0 -55
- package/src/streams/webhook-queue.ts +0 -15
- /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
package/src/publisher/wings.ts
CHANGED
|
@@ -1,124 +1,204 @@
|
|
|
1
|
-
import type { PartitionValue } from "@useairfoil/wings";
|
|
2
1
|
import * as Wings from "@useairfoil/wings";
|
|
3
2
|
import { Effect, Layer } from "effect";
|
|
4
|
-
|
|
5
|
-
import type { ConnectorDefinition } from "../core/types";
|
|
6
|
-
|
|
3
|
+
|
|
4
|
+
import type { ConnectorDefinition, ResourceDefinition } from "../core/types";
|
|
5
|
+
|
|
6
|
+
import { ConnectorError } from "../errors";
|
|
7
|
+
import { SpanName } from "../telemetry";
|
|
8
|
+
import { Publisher, type PublishAck, type PublisherService } from "./service";
|
|
7
9
|
|
|
8
10
|
type Rows = Record<string, unknown>;
|
|
9
11
|
|
|
12
|
+
export type WingsTableMapping = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly partitionValue?: Wings.PartitionValue.PartitionValue;
|
|
15
|
+
};
|
|
16
|
+
|
|
10
17
|
export type WingsPublisherConfig = {
|
|
11
18
|
readonly connector: ConnectorDefinition;
|
|
12
|
-
|
|
13
|
-
readonly topics: Record<string, Wings.Cluster.Topic.Topic>;
|
|
14
|
-
/** per-stream partition value (key is entity/event name). */
|
|
15
|
-
readonly partitionValues?: Record<string, PartitionValue>;
|
|
19
|
+
readonly tables: Record<string, string | WingsTableMapping>;
|
|
16
20
|
};
|
|
17
21
|
|
|
18
|
-
/** Publisher entry for a single entity/event. */
|
|
19
22
|
type PublisherEntry = {
|
|
20
|
-
|
|
23
|
+
readonly resource: ResourceDefinition;
|
|
24
|
+
readonly table: Wings.Cluster.Table.Table;
|
|
21
25
|
readonly publisher: Wings.WingsClient.Publisher;
|
|
22
|
-
|
|
23
|
-
readonly
|
|
24
|
-
|
|
25
|
-
readonly partitionValue?: PartitionValue;
|
|
26
|
+
readonly partitionValue?: Wings.PartitionValue.PartitionValue;
|
|
27
|
+
readonly keyField: Wings.Cluster.ArrowType.Field;
|
|
28
|
+
readonly versionField: Wings.Cluster.ArrowType.Field;
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
const normalizeMapping = (mapping: string | WingsTableMapping): WingsTableMapping =>
|
|
32
|
+
typeof mapping === "string" ? { name: mapping } : mapping;
|
|
33
|
+
|
|
28
34
|
const buildRecordBatch = (rows: ReadonlyArray<Rows>) => {
|
|
29
|
-
|
|
30
|
-
const table = Wings.tableFromJSON(Array.from(rows));
|
|
35
|
+
const table = Wings.Arrow.tableFromJSON(Array.from(rows));
|
|
31
36
|
const [batch] = table.batches;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return batch;
|
|
37
|
+
return batch
|
|
38
|
+
? Effect.succeed(batch)
|
|
39
|
+
: Effect.fail(new ConnectorError({ message: "No rows to publish" }));
|
|
38
40
|
};
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
const validateTableMapping = (options: {
|
|
43
|
+
readonly resource: ResourceDefinition;
|
|
44
|
+
readonly table: Wings.Cluster.Table.Table;
|
|
45
|
+
readonly partitionValue?: Wings.PartitionValue.PartitionValue;
|
|
46
|
+
}) =>
|
|
47
|
+
Effect.try({
|
|
48
|
+
try: () => {
|
|
49
|
+
const keyField = Wings.TableUtils.getKeyField(options.table);
|
|
50
|
+
const versionField = Wings.TableUtils.getVersionField(options.table);
|
|
51
|
+
const partitionField = Wings.TableUtils.getPartitionField(options.table);
|
|
52
|
+
|
|
53
|
+
if (keyField.name !== options.resource.key) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Resource ${options.resource.name} key '${options.resource.key}' does not match Wings table key '${keyField.name}'`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (versionField.name !== options.resource.version) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Resource ${options.resource.name} version '${options.resource.version}' does not match Wings table version '${versionField.name}'`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (options.resource.partition?.required === true && !options.partitionValue) {
|
|
64
|
+
throw new Error(`Resource ${options.resource.name} requires a partition value`);
|
|
65
|
+
}
|
|
66
|
+
if (partitionField && !options.partitionValue) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Wings table ${options.table.name} is partitioned but no partition value was provided`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!partitionField && options.partitionValue) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Wings table ${options.table.name} is not partitioned but a partition value was provided`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { keyField, versionField };
|
|
78
|
+
},
|
|
79
|
+
catch: (cause) =>
|
|
80
|
+
new ConnectorError({
|
|
81
|
+
message: `Invalid Wings table mapping for ${options.resource.name}`,
|
|
82
|
+
cause,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const accepted = (entry: PublisherEntry): PublishAck => ({
|
|
87
|
+
status: "accepted",
|
|
88
|
+
resource: entry.resource.name,
|
|
89
|
+
partition: entry.partitionValue,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const rejected = (entry: PublisherEntry, reason: string, rejectedRows: number): PublishAck => ({
|
|
93
|
+
status: "rejected",
|
|
94
|
+
resource: entry.resource.name,
|
|
95
|
+
reason,
|
|
96
|
+
rejectedRows,
|
|
97
|
+
partition: entry.partitionValue,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const layerWings = (
|
|
41
101
|
config: WingsPublisherConfig,
|
|
42
102
|
): Layer.Layer<Publisher, ConnectorError, Wings.WingsClient.WingsClient> =>
|
|
43
103
|
Layer.effect(Publisher)(
|
|
44
104
|
Effect.gen(function* () {
|
|
45
105
|
const entries = new Map<string, PublisherEntry>();
|
|
106
|
+
const clusterClient = yield* Wings.WingsClient.clusterClient;
|
|
46
107
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
...config.connector.events,
|
|
51
|
-
]) {
|
|
52
|
-
const topic = config.topics[def.name];
|
|
53
|
-
if (!topic) {
|
|
108
|
+
for (const resource of config.connector.resources) {
|
|
109
|
+
const mappingInput = config.tables[resource.name];
|
|
110
|
+
if (!mappingInput) {
|
|
54
111
|
return yield* Effect.fail(
|
|
55
|
-
new ConnectorError({ message: `Missing
|
|
112
|
+
new ConnectorError({ message: `Missing table for ${resource.name}` }),
|
|
56
113
|
);
|
|
57
114
|
}
|
|
58
115
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
? topic.schema.fields.findIndex(
|
|
62
|
-
(field) => field.id === topic.partitionKey,
|
|
63
|
-
)
|
|
64
|
-
: undefined;
|
|
65
|
-
|
|
66
|
-
const partitionField =
|
|
67
|
-
partitionIndex !== undefined && partitionIndex >= 0
|
|
68
|
-
? topic.schema.fields[partitionIndex]?.name
|
|
69
|
-
: undefined;
|
|
70
|
-
|
|
71
|
-
const publisher = yield* Wings.WingsClient.publisher({
|
|
72
|
-
topic,
|
|
73
|
-
partitionValue: config.partitionValues?.[def.name],
|
|
74
|
-
}).pipe(
|
|
116
|
+
const mapping = normalizeMapping(mappingInput);
|
|
117
|
+
const table = yield* clusterClient.getTable({ name: mapping.name }).pipe(
|
|
75
118
|
Effect.mapError(
|
|
76
119
|
(error) =>
|
|
77
|
-
new ConnectorError({
|
|
120
|
+
new ConnectorError({
|
|
121
|
+
message: `Failed to resolve Wings table for ${resource.name}`,
|
|
122
|
+
cause: error,
|
|
123
|
+
}),
|
|
78
124
|
),
|
|
79
125
|
);
|
|
126
|
+
const { keyField, versionField } = yield* validateTableMapping({
|
|
127
|
+
resource,
|
|
128
|
+
table,
|
|
129
|
+
partitionValue: mapping.partitionValue,
|
|
130
|
+
});
|
|
80
131
|
|
|
81
|
-
|
|
132
|
+
const publisher = yield* Wings.WingsClient.publisher({
|
|
133
|
+
table,
|
|
134
|
+
partitionValue: mapping.partitionValue,
|
|
135
|
+
}).pipe(
|
|
136
|
+
Effect.mapError((error) => new ConnectorError({ message: error.message, cause: error })),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
entries.set(resource.name, {
|
|
140
|
+
resource,
|
|
141
|
+
table,
|
|
82
142
|
publisher,
|
|
83
|
-
|
|
84
|
-
|
|
143
|
+
partitionValue: mapping.partitionValue,
|
|
144
|
+
keyField,
|
|
145
|
+
versionField,
|
|
85
146
|
});
|
|
86
147
|
}
|
|
87
148
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
149
|
+
const pushRows = (
|
|
150
|
+
entry: PublisherEntry,
|
|
151
|
+
operation: "upsert" | "delete",
|
|
152
|
+
rows: ReadonlyArray<Rows>,
|
|
153
|
+
) =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
if (rows.length === 0) return accepted(entry);
|
|
156
|
+
const batch = yield* buildRecordBatch(rows);
|
|
157
|
+
return yield* entry.publisher
|
|
158
|
+
.push({ operation, batch, partitionValue: entry.partitionValue })
|
|
159
|
+
.pipe(
|
|
160
|
+
Effect.matchEffect({
|
|
161
|
+
onFailure: (error) => Effect.succeed(rejected(entry, error.message, rows.length)),
|
|
162
|
+
onSuccess: () => Effect.succeed(accepted(entry)),
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
97
166
|
|
|
98
|
-
|
|
99
|
-
|
|
167
|
+
const service: PublisherService = {
|
|
168
|
+
publish: Effect.fn(SpanName.publish, { kind: "producer" })(function* ({
|
|
169
|
+
resource,
|
|
170
|
+
source: _source,
|
|
171
|
+
batch,
|
|
172
|
+
}) {
|
|
173
|
+
const entry = entries.get(resource);
|
|
174
|
+
if (!entry) {
|
|
175
|
+
return yield* Effect.fail(
|
|
176
|
+
new ConnectorError({ message: `Unknown resource ${resource}` }),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (batch.mutations.length === 0) return accepted(entry);
|
|
181
|
+
|
|
182
|
+
const upserts: Rows[] = [];
|
|
183
|
+
const deletes: Rows[] = [];
|
|
184
|
+
for (const mutation of batch.mutations) {
|
|
185
|
+
if (mutation.op === "upsert") {
|
|
186
|
+
upserts.push(mutation.row as Rows);
|
|
187
|
+
} else {
|
|
188
|
+
deletes.push({
|
|
189
|
+
[entry.keyField.name]: mutation.key,
|
|
190
|
+
[entry.versionField.name]: mutation.version,
|
|
191
|
+
});
|
|
100
192
|
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const upsertAck = yield* pushRows(entry, "upsert", upserts);
|
|
196
|
+
if (upsertAck.status === "rejected") return upsertAck;
|
|
101
197
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
.push({
|
|
105
|
-
batch: recordBatch,
|
|
106
|
-
partitionValue: entry.partitionValue,
|
|
107
|
-
})
|
|
108
|
-
.pipe(
|
|
109
|
-
Effect.mapError(
|
|
110
|
-
(error) =>
|
|
111
|
-
new ConnectorError({
|
|
112
|
-
message: error.message,
|
|
113
|
-
cause: error,
|
|
114
|
-
}),
|
|
115
|
-
),
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
success: !!(result.result && result.result.$case === "accepted"),
|
|
120
|
-
};
|
|
121
|
-
}),
|
|
198
|
+
return yield* pushRows(entry, "delete", deletes);
|
|
199
|
+
}),
|
|
122
200
|
};
|
|
201
|
+
|
|
202
|
+
return Publisher.of(service);
|
|
123
203
|
}),
|
|
124
204
|
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { ConnectorDefinition } from "../core/types";
|
|
4
|
+
|
|
5
|
+
export type ConnectorRuntimeContextValue = {
|
|
6
|
+
readonly connector: ConnectorDefinition;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class ConnectorRuntimeContext extends Context.Service<
|
|
10
|
+
ConnectorRuntimeContext,
|
|
11
|
+
ConnectorRuntimeContextValue
|
|
12
|
+
>()("@useairfoil/connector-kit/ConnectorRuntimeContext") {}
|
|
13
|
+
|
|
14
|
+
export const layer = (connector: ConnectorDefinition) =>
|
|
15
|
+
Layer.succeed(ConnectorRuntimeContext)(ConnectorRuntimeContext.of({ connector }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Effect, Layer, Ref } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { ResourceState } from "../core/types";
|
|
4
|
+
|
|
5
|
+
import { StateStore } from "./service";
|
|
6
|
+
|
|
7
|
+
/** In-memory resource state store backed by a `Ref<Map>`. Suitable for development and tests. */
|
|
8
|
+
export const layerMemory: Layer.Layer<StateStore> = Layer.effect(StateStore)(
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const ref = yield* Ref.make(new Map<string, ResourceState>());
|
|
11
|
+
|
|
12
|
+
return StateStore.of({
|
|
13
|
+
getResourceState: (resource) => Effect.map(Ref.get(ref), (map) => map.get(resource)),
|
|
14
|
+
setResourceState: (resource, state) =>
|
|
15
|
+
Effect.map(
|
|
16
|
+
Ref.update(ref, (map) => {
|
|
17
|
+
const next = new Map(map);
|
|
18
|
+
next.set(resource, state);
|
|
19
|
+
return next;
|
|
20
|
+
}),
|
|
21
|
+
() => undefined,
|
|
22
|
+
),
|
|
23
|
+
});
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Context, type Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { ResourceState } from "../core/types";
|
|
4
|
+
import type { ConnectorError } from "../errors";
|
|
5
|
+
|
|
6
|
+
export interface StateStoreService {
|
|
7
|
+
readonly getResourceState: (
|
|
8
|
+
resource: string,
|
|
9
|
+
) => Effect.Effect<ResourceState | undefined, ConnectorError>;
|
|
10
|
+
readonly setResourceState: (
|
|
11
|
+
resource: string,
|
|
12
|
+
state: ResourceState,
|
|
13
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class StateStore extends Context.Service<StateStore, StateStoreService>()(
|
|
17
|
+
"@useairfoil/connector-kit/StateStore",
|
|
18
|
+
) {}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Cause, Config, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { Headers } from "effect/unstable/http";
|
|
3
|
+
import * as Observability from "effect/unstable/observability";
|
|
4
|
+
|
|
5
|
+
/** Canonical span names emitted by connector-kit runtime instrumentation. */
|
|
6
|
+
export const SpanName = {
|
|
7
|
+
batchProcess: "connector.batch.process",
|
|
8
|
+
webhookDecode: "connector.webhook.decode",
|
|
9
|
+
webhookHandle: "connector.webhook.handle",
|
|
10
|
+
publish: "connector.publish",
|
|
11
|
+
stateSet: "connector.state.set",
|
|
12
|
+
apiFetch: "connector.api.fetch",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/** Canonical span attribute names. Keep these stable for collector queries. */
|
|
16
|
+
export const Attr = {
|
|
17
|
+
connectorName: "airfoil.connector.name",
|
|
18
|
+
connectorResourcesCount: "airfoil.connector.resources.count",
|
|
19
|
+
resourceName: "airfoil.resource.name",
|
|
20
|
+
resourceSource: "airfoil.resource.source",
|
|
21
|
+
batchMutations: "airfoil.batch.mutations",
|
|
22
|
+
webhookPath: "airfoil.webhook.path",
|
|
23
|
+
publisherSuccess: "airfoil.publisher.success",
|
|
24
|
+
stateKey: "airfoil.state.key",
|
|
25
|
+
apiPath: "airfoil.api.path",
|
|
26
|
+
errorPhase: "airfoil.error.phase",
|
|
27
|
+
errorType: "airfoil.error.type",
|
|
28
|
+
errorMessage: "airfoil.error.message",
|
|
29
|
+
errorDetails: "airfoil.error.details",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** Span event names for high-cardinality reproduction details. */
|
|
33
|
+
export const EventName = {
|
|
34
|
+
batchCheckpoint: "airfoil.batch.checkpoint",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** Event-only attributes. These are intentionally not span attributes. */
|
|
38
|
+
export const EventAttr = {
|
|
39
|
+
batchCursor: "airfoil.batch.cursor",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
43
|
+
typeof value === "object" && value !== null;
|
|
44
|
+
|
|
45
|
+
const maxErrorMessageLength = 500;
|
|
46
|
+
|
|
47
|
+
const OtlpEnvConfig = Config.all({
|
|
48
|
+
enabled: Config.boolean("OTEL_ENABLED").pipe(Config.withDefault(false)),
|
|
49
|
+
baseUrl: Config.option(Config.string("OTEL_EXPORTER_OTLP_ENDPOINT")),
|
|
50
|
+
rawHeaders: Config.option(Config.string("OTEL_EXPORTER_OTLP_HEADERS")),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const defaultRedactedHeaders: ReadonlyArray<string | RegExp> = [
|
|
54
|
+
"authorization",
|
|
55
|
+
"cookie",
|
|
56
|
+
"set-cookie",
|
|
57
|
+
"x-api-key",
|
|
58
|
+
/api[-_]?key/i,
|
|
59
|
+
/secret/i,
|
|
60
|
+
/signature/i,
|
|
61
|
+
/token/i,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const parseOtelHeaders = (value: string): Record<string, string> =>
|
|
65
|
+
Object.fromEntries(
|
|
66
|
+
value
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((entry) => entry.trim())
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.flatMap((entry) => {
|
|
71
|
+
const separator = entry.indexOf("=");
|
|
72
|
+
if (separator < 1) return [];
|
|
73
|
+
return [[entry.slice(0, separator).trim(), entry.slice(separator + 1).trim()]];
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const appendPath = (baseUrl: string, path: string) =>
|
|
78
|
+
`${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Best-effort stable error classifier for span metadata.
|
|
82
|
+
*
|
|
83
|
+
* Effect/Data tagged errors expose `_tag`; that is the most useful value for
|
|
84
|
+
* connector bugs (`ConnectorError`, `InvalidWebhookPayloadError`, etc.). For
|
|
85
|
+
* non-tagged errors we fall back to the JS `Error.name`, then constructor name,
|
|
86
|
+
* then the primitive type.
|
|
87
|
+
*/
|
|
88
|
+
const errorType = (error: unknown) => {
|
|
89
|
+
if (isRecord(error) && typeof error._tag === "string") {
|
|
90
|
+
return String(error._tag);
|
|
91
|
+
}
|
|
92
|
+
if (error instanceof Error) {
|
|
93
|
+
return error.name;
|
|
94
|
+
}
|
|
95
|
+
if (isRecord(error) && typeof error.constructor?.name === "string") {
|
|
96
|
+
return error.constructor.name;
|
|
97
|
+
}
|
|
98
|
+
return typeof error;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Human-readable error summary for span metadata.
|
|
103
|
+
*
|
|
104
|
+
* Tagged errors in this package carry a `message` field, so this preserves the
|
|
105
|
+
* important diagnostic text. Unknown object failures are intentionally reduced
|
|
106
|
+
* to their type; full structured error payloads belong in logs, not span attrs.
|
|
107
|
+
*/
|
|
108
|
+
const errorMessage = (error: unknown) => {
|
|
109
|
+
const message = (() => {
|
|
110
|
+
if (isRecord(error) && typeof error.message === "string") {
|
|
111
|
+
return error.message;
|
|
112
|
+
}
|
|
113
|
+
if (typeof error === "string") {
|
|
114
|
+
return error;
|
|
115
|
+
}
|
|
116
|
+
return errorType(error);
|
|
117
|
+
})();
|
|
118
|
+
|
|
119
|
+
if (message.length > maxErrorMessageLength) {
|
|
120
|
+
return `${message.slice(0, maxErrorMessageLength)}...`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return message;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const errorDetails = (error: unknown): string | undefined => {
|
|
127
|
+
if (!isRecord(error) || error.cause === undefined) return undefined;
|
|
128
|
+
return Cause.pretty(Cause.die(error.cause));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Annotates the active span with phase-aware error metadata before re-failing. */
|
|
132
|
+
export const annotateError = (phase: string, error: unknown) => {
|
|
133
|
+
const details = errorDetails(error);
|
|
134
|
+
return Effect.annotateCurrentSpan({
|
|
135
|
+
[Attr.errorPhase]: phase,
|
|
136
|
+
[Attr.errorType]: errorType(error),
|
|
137
|
+
[Attr.errorMessage]: errorMessage(error),
|
|
138
|
+
...(details !== undefined ? { [Attr.errorDetails]: details } : {}),
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Adds an event to the active span, or no-ops when no span is active. */
|
|
143
|
+
export const addCurrentSpanEvent = (name: string, attributes?: Record<string, unknown>) =>
|
|
144
|
+
Effect.currentSpan.pipe(
|
|
145
|
+
Effect.flatMap((span) =>
|
|
146
|
+
Effect.clockWith((clock) =>
|
|
147
|
+
Effect.sync(() => span.event(name, clock.currentTimeNanosUnsafe(), attributes)),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
Effect.ignore,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/** Transport configuration for OTLP trace export. All fields are direct values. */
|
|
154
|
+
export type OtlpTracingConfig = {
|
|
155
|
+
readonly enabled?: boolean;
|
|
156
|
+
readonly endpoint?: string;
|
|
157
|
+
readonly headers?: Record<string, string>;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Runtime options — not config-wrappable since RegExp cannot live in Config. */
|
|
161
|
+
export type OtlpTracingOptions = {
|
|
162
|
+
/** Additional connector-specific header names or regexes to redact from HTTP logs/traces. */
|
|
163
|
+
readonly redactedHeaders?: ReadonlyArray<string | RegExp>;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const buildOtlpTracingLayer = (config: OtlpTracingConfig, options: OtlpTracingOptions) =>
|
|
167
|
+
Effect.gen(function* () {
|
|
168
|
+
const allRedacted = [...defaultRedactedHeaders, ...(options.redactedHeaders ?? [])];
|
|
169
|
+
const RedactionLayer = Layer.succeed(Headers.CurrentRedactedNames)(allRedacted);
|
|
170
|
+
|
|
171
|
+
if (!config.enabled) {
|
|
172
|
+
return RedactionLayer;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!config.endpoint) {
|
|
176
|
+
return yield* Effect.fail(new Error("enabled=true requires endpoint"));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
yield* Effect.logInfo("telemetry enabled").pipe(
|
|
180
|
+
Effect.annotateLogs({
|
|
181
|
+
endpoint: config.endpoint,
|
|
182
|
+
headers: config.headers ? Object.keys(config.headers) : [],
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return Layer.mergeAll(
|
|
187
|
+
RedactionLayer,
|
|
188
|
+
Observability.OtlpTracer.layer({
|
|
189
|
+
url: appendPath(config.endpoint, "/v1/traces"),
|
|
190
|
+
headers: config.headers,
|
|
191
|
+
}).pipe(Layer.provide(Observability.OtlpSerialization.layerJson)),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Trace-only OTLP export with sensitive HTTP header redaction.
|
|
197
|
+
* Takes direct config values — no `ConfigProvider` required.
|
|
198
|
+
*/
|
|
199
|
+
export const layer = (config: OtlpTracingConfig = {}, options: OtlpTracingOptions = {}) =>
|
|
200
|
+
Layer.unwrap(buildOtlpTracingLayer(config, options));
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Trace-only OTLP export with sensitive HTTP header redaction.
|
|
204
|
+
* Takes Effect Config-wrapped values — reads from `ConfigProvider`.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* Telemetry.layerConfig({
|
|
208
|
+
* enabled: Config.boolean("MY_OTEL_ENABLED"),
|
|
209
|
+
* endpoint: Config.string("MY_COLLECTOR_URL"),
|
|
210
|
+
* })
|
|
211
|
+
*/
|
|
212
|
+
export const layerConfig = (
|
|
213
|
+
config: Config.Wrap<OtlpTracingConfig>,
|
|
214
|
+
options: OtlpTracingOptions = {},
|
|
215
|
+
) =>
|
|
216
|
+
Layer.unwrap(
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const resolved = yield* Config.unwrap(config);
|
|
219
|
+
return yield* buildOtlpTracingLayer(resolved, options);
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Trace-only OTLP export with sensitive HTTP header redaction.
|
|
225
|
+
* Zero-config shortcut that reads the standard `OTEL_*` env var names.
|
|
226
|
+
*
|
|
227
|
+
* Reads `OTEL_ENABLED`, `OTEL_EXPORTER_OTLP_ENDPOINT`, and
|
|
228
|
+
* `OTEL_EXPORTER_OTLP_HEADERS`. Effect's OTLP resource handling reads
|
|
229
|
+
* `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, and `OTEL_RESOURCE_ATTRIBUTES`.
|
|
230
|
+
*/
|
|
231
|
+
export const layerOtlpTracing = (options: OtlpTracingOptions = {}) =>
|
|
232
|
+
Layer.unwrap(
|
|
233
|
+
Effect.gen(function* () {
|
|
234
|
+
const raw = yield* OtlpEnvConfig;
|
|
235
|
+
return yield* buildOtlpTracingLayer(
|
|
236
|
+
{
|
|
237
|
+
enabled: raw.enabled,
|
|
238
|
+
endpoint: Option.getOrUndefined(raw.baseUrl),
|
|
239
|
+
headers: Option.isSome(raw.rawHeaders)
|
|
240
|
+
? parseOtelHeaders(raw.rawHeaders.value)
|
|
241
|
+
: undefined,
|
|
242
|
+
},
|
|
243
|
+
options,
|
|
244
|
+
);
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResourceDefinition, WebhookRoute, WebhookRouteInput } from "../core/types";
|
|
2
|
+
|
|
3
|
+
export { router } from "./server";
|
|
4
|
+
export type { WebhookAckMode, WebhookRoute, WebhookRouteContext, WebhookRouteInput } from "./types";
|
|
5
|
+
|
|
6
|
+
export const route = <const Resources extends ReadonlyArray<ResourceDefinition>, Payload>(
|
|
7
|
+
definition: WebhookRouteInput<Resources, Payload>,
|
|
8
|
+
): WebhookRoute<Resources, Payload> => definition;
|