@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.
Files changed (67) hide show
  1. package/README.md +279 -90
  2. package/dist/chunk-Bp6m_JJh.js +13 -0
  3. package/dist/connector-app/index.d.ts +9 -0
  4. package/dist/connector-app/index.js +8 -0
  5. package/dist/connector-app-CvqbfN6R.js +32 -0
  6. package/dist/connector-app-CvqbfN6R.js.map +1 -0
  7. package/dist/errors/index.d.ts +2 -0
  8. package/dist/errors/index.js +3 -0
  9. package/dist/errors-DHWylqRZ.js +8 -0
  10. package/dist/errors-DHWylqRZ.js.map +1 -0
  11. package/dist/index-8KnVpoZp.d.ts +32 -0
  12. package/dist/index-BlhJCpEk.d.ts +124 -0
  13. package/dist/index-CQJsFJs3.d.ts +23 -0
  14. package/dist/index-DK9YcR6W.d.ts +25 -0
  15. package/dist/index-DaNW63Mv.d.ts +21 -0
  16. package/dist/index-DyuT2jzP.d.ts +14 -0
  17. package/dist/index.d.ts +9 -166
  18. package/dist/index.js +44 -229
  19. package/dist/index.js.map +1 -1
  20. package/dist/ingestion/index.d.ts +6 -0
  21. package/dist/ingestion/index.js +7 -0
  22. package/dist/ingestion-CMCroK7Y.js +171 -0
  23. package/dist/ingestion-CMCroK7Y.js.map +1 -0
  24. package/dist/publisher/index.d.ts +5 -0
  25. package/dist/publisher/index.js +5 -0
  26. package/dist/publisher-DusgYvaV.js +131 -0
  27. package/dist/publisher-DusgYvaV.js.map +1 -0
  28. package/dist/server-DAeFWT4o.js +113 -0
  29. package/dist/server-DAeFWT4o.js.map +1 -0
  30. package/dist/service-B7g_lHMI.d.ts +32 -0
  31. package/dist/state-store/index.d.ts +4 -0
  32. package/dist/state-store/index.js +3 -0
  33. package/dist/state-store-DG_4QpWG.js +31 -0
  34. package/dist/state-store-DG_4QpWG.js.map +1 -0
  35. package/dist/telemetry-p0dE5Mdu.js +171 -0
  36. package/dist/telemetry-p0dE5Mdu.js.map +1 -0
  37. package/dist/types-ZNrA-Dn0.d.ts +160 -0
  38. package/dist/webhook/index.d.ts +5 -0
  39. package/dist/webhook/index.js +6 -0
  40. package/dist/webhook-BSA-tXtU.js +13 -0
  41. package/dist/webhook-BSA-tXtU.js.map +1 -0
  42. package/package.json +41 -14
  43. package/src/connector-app/index.ts +1 -0
  44. package/src/connector-app/start.ts +38 -0
  45. package/src/core/builder.ts +73 -15
  46. package/src/core/index.ts +22 -0
  47. package/src/core/types.ts +186 -78
  48. package/src/errors/index.ts +1 -0
  49. package/src/index.ts +8 -28
  50. package/src/ingestion/engine.ts +302 -206
  51. package/src/ingestion/index.ts +1 -0
  52. package/src/publisher/console.ts +18 -0
  53. package/src/publisher/index.ts +7 -0
  54. package/src/publisher/service.ts +33 -14
  55. package/src/publisher/wings.ts +162 -82
  56. package/src/runtime/context.ts +15 -0
  57. package/src/state-store/index.ts +2 -0
  58. package/src/state-store/memory.ts +25 -0
  59. package/src/state-store/service.ts +18 -0
  60. package/src/telemetry.ts +246 -0
  61. package/src/webhook/index.ts +8 -0
  62. package/src/webhook/server.ts +177 -54
  63. package/src/webhook/types.ts +6 -16
  64. package/src/ingestion/state-store.ts +0 -36
  65. package/src/streams/pull-stream.ts +0 -55
  66. package/src/streams/webhook-queue.ts +0 -15
  67. /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
@@ -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
- import { ConnectorError } from "../core/errors";
5
- import type { ConnectorDefinition } from "../core/types";
6
- import { Publisher } from "./service";
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
- /** Map of entity/event name to Wings topic. */
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
- /** Wings publisher for the entity/event. */
23
+ readonly resource: ResourceDefinition;
24
+ readonly table: Wings.Cluster.Table.Table;
21
25
  readonly publisher: Wings.WingsClient.Publisher;
22
- /** Partition field name (if any). */
23
- readonly partitionField?: string;
24
- /** Partition value (if any). */
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
- // Convert JSON rows into an Arrow RecordBatch for Wings.
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
- if (!batch) {
34
- throw new ConnectorError({ message: "No rows to publish" });
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
- export const WingsPublisherLayer = (
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
- // create and store a wings publisher for each entity/event.
48
- for (const def of [
49
- ...config.connector.entities,
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 topic for ${def.name}` }),
112
+ new ConnectorError({ message: `Missing table for ${resource.name}` }),
56
113
  );
57
114
  }
58
115
 
59
- const partitionIndex =
60
- topic.partitionKey !== undefined
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({ message: error.message, cause: error }),
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
- entries.set(def.name, {
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
- partitionField,
84
- partitionValue: config.partitionValues?.[def.name],
143
+ partitionValue: mapping.partitionValue,
144
+ keyField,
145
+ versionField,
85
146
  });
86
147
  }
87
148
 
88
- return {
89
- publish: ({ name, batch }) =>
90
- Effect.gen(function* () {
91
- const entry = entries.get(name);
92
- if (!entry) {
93
- return yield* Effect.fail(
94
- new ConnectorError({ message: `Unknown stream ${name}` }),
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
- if (batch.rows.length === 0) {
99
- return { success: true };
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
- const recordBatch = buildRecordBatch(batch.rows);
103
- const result = yield* entry.publisher
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,2 @@
1
+ export { layerMemory } from "./memory";
2
+ export { StateStore, type StateStoreService } from "./service";
@@ -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
+ ) {}
@@ -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;