@useairfoil/connector-kit 0.4.0 → 0.5.1
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/METRICS.md +78 -0
- package/README.md +450 -88
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth-DvU_CZ7C.js +42 -0
- package/dist/auth-DvU_CZ7C.js.map +1 -0
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/connector-app/index.d.ts +14 -0
- package/dist/connector-app/index.js +11 -0
- package/dist/connector-app-d7Z-SZLQ.js +64 -0
- package/dist/connector-app-d7Z-SZLQ.js.map +1 -0
- package/dist/constants-BRhIX_rz.js +27 -0
- package/dist/constants-BRhIX_rz.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-Byr0Lf0C.d.ts +25 -0
- package/dist/index-CBT-JX-T.d.ts +173 -0
- package/dist/index-CTHVudvQ.d.ts +41 -0
- package/dist/index-CTLYQ74P.d.ts +80 -0
- package/dist/index-Coh_SkBs.d.ts +95 -0
- package/dist/index-D54ySAKn.d.ts +14 -0
- package/dist/index-DtJX_tic.d.ts +153 -0
- package/dist/index-J42AZaXU.d.ts +38 -0
- package/dist/index-fIIBVAai.d.ts +25 -0
- package/dist/index-o1-Z3n7V.d.ts +55 -0
- package/dist/index.d.ts +14 -165
- package/dist/index.js +50 -228
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +6 -0
- package/dist/ingestion/index.js +10 -0
- package/dist/ingestion-BQo4cbGw.js +200 -0
- package/dist/ingestion-BQo4cbGw.js.map +1 -0
- package/dist/manifest/index.d.ts +2 -0
- package/dist/manifest/index.js +4 -0
- package/dist/manifest-ByE6YPG0.js +221 -0
- package/dist/manifest-ByE6YPG0.js.map +1 -0
- package/dist/metrics-CVAk1ALR.d.ts +53 -0
- package/dist/metrics-CWPy2Aqc.js +96 -0
- package/dist/metrics-CWPy2Aqc.js.map +1 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +3 -0
- package/dist/publisher/index.d.ts +5 -0
- package/dist/publisher/index.js +6 -0
- package/dist/publisher-BYT3RLVO.js +131 -0
- package/dist/publisher-BYT3RLVO.js.map +1 -0
- package/dist/runtime-config/index.d.ts +2 -0
- package/dist/runtime-config/index.js +4 -0
- package/dist/runtime-config-BpB-d_bF.js +51 -0
- package/dist/runtime-config-BpB-d_bF.js.map +1 -0
- package/dist/server-CXTQ7odl.js +183 -0
- package/dist/server-CXTQ7odl.js.map +1 -0
- package/dist/service-DuFiQwid.js +191 -0
- package/dist/service-DuFiQwid.js.map +1 -0
- package/dist/service-IE5qaf9x.d.ts +32 -0
- package/dist/state-store/index.d.ts +3 -0
- package/dist/state-store/index.js +5 -0
- package/dist/state-store-yl6LiTQ0.js +198 -0
- package/dist/state-store-yl6LiTQ0.js.map +1 -0
- package/dist/status/index.d.ts +5 -0
- package/dist/status/index.js +6 -0
- package/dist/status-B-oZBc33.js +84 -0
- package/dist/status-B-oZBc33.js.map +1 -0
- package/dist/types-B35J5YPu.d.ts +174 -0
- package/dist/webhook/index.d.ts +6 -0
- package/dist/webhook/index.js +8 -0
- package/dist/webhook-DI-Afxkg.js +13 -0
- package/dist/webhook-DI-Afxkg.js.map +1 -0
- package/package.json +66 -8
- package/src/auth/index.ts +1 -0
- package/src/auth/token-cache.ts +54 -0
- package/src/connector-app/check.ts +76 -0
- package/src/connector-app/index.ts +2 -0
- package/src/connector-app/start.ts +42 -0
- package/src/core/builder.ts +81 -9
- package/src/core/index.ts +22 -0
- package/src/core/types.ts +204 -76
- package/src/errors/index.ts +1 -0
- package/src/index.ts +13 -28
- package/src/ingestion/engine.ts +409 -174
- package/src/ingestion/index.ts +1 -0
- package/src/manifest/config.ts +232 -0
- package/src/manifest/index.ts +14 -0
- package/src/manifest/manifest.ts +31 -0
- package/src/manifest/number-schema.ts +17 -0
- package/src/manifest/schema.ts +160 -0
- package/src/metrics.ts +128 -0
- package/src/publisher/console.ts +18 -0
- package/src/publisher/index.ts +7 -0
- package/src/publisher/instrumented.ts +68 -0
- package/src/publisher/service.ts +30 -13
- package/src/publisher/wings.ts +159 -72
- package/src/runtime/context.ts +15 -0
- package/src/runtime-config/constants.ts +27 -0
- package/src/runtime-config/error.ts +13 -0
- package/src/runtime-config/index.ts +3 -0
- package/src/runtime-config/layer.ts +56 -0
- package/src/state-store/index.ts +3 -0
- package/src/state-store/keys.ts +22 -0
- package/src/state-store/layer-key-value-store.ts +132 -0
- package/src/state-store/layer-memory.ts +11 -0
- package/src/state-store/layer-sql.ts +50 -0
- package/src/state-store/schema.ts +57 -0
- package/src/state-store/service.ts +51 -0
- package/src/state-store/state.ts +17 -0
- package/src/status/index.ts +2 -0
- package/src/status/route.ts +77 -0
- package/src/status/types.ts +33 -0
- package/src/telemetry.ts +288 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/server.ts +209 -39
- package/src/webhook/types.ts +6 -17
- package/src/ingestion/state-store.ts +0 -36
- package/src/streams/pull-stream.ts +0 -54
- package/src/streams/webhook-queue.ts +0 -14
- /package/src/{core/errors.ts → errors/connector-error.ts} +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const CursorValueSchema = Schema.Union([Schema.String, Schema.Number]);
|
|
4
|
+
|
|
5
|
+
/** Durable progress for a finite historical backfill. */
|
|
6
|
+
export const BackfillStateSchema = Schema.Struct({
|
|
7
|
+
cutoff: CursorValueSchema,
|
|
8
|
+
pageCursor: Schema.optional(CursorValueSchema),
|
|
9
|
+
completed: Schema.Boolean,
|
|
10
|
+
lastSuccessAt: Schema.optional(Schema.String),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Durable cursor for an ongoing incremental changes stream. */
|
|
14
|
+
export const ChangesStateSchema = Schema.Struct({
|
|
15
|
+
cursor: CursorValueSchema,
|
|
16
|
+
lastSuccessAt: Schema.optional(Schema.String),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const StateSourceSchema = Schema.Literals(["backfill", "changes"]);
|
|
20
|
+
|
|
21
|
+
export const StateOperationSchema = Schema.Literals(["fetch", "publish", "checkpoint"]);
|
|
22
|
+
|
|
23
|
+
export const StateErrorCodeSchema = Schema.Literals([
|
|
24
|
+
"fetch_failed",
|
|
25
|
+
"publish_failed",
|
|
26
|
+
"checkpoint_failed",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** SDK-owned error context safe to persist and expose through status. */
|
|
30
|
+
export const ResourceErrorContextSchema = Schema.Struct({
|
|
31
|
+
source: StateSourceSchema,
|
|
32
|
+
operation: StateOperationSchema,
|
|
33
|
+
code: StateErrorCodeSchema,
|
|
34
|
+
message: Schema.String,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Stable public error summary persisted for status reporting; never a raw cause. */
|
|
38
|
+
export const ResourceErrorSchema = Schema.Struct({
|
|
39
|
+
...ResourceErrorContextSchema.fields,
|
|
40
|
+
at: Schema.String,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Complete persisted state assembled from independently stored components. */
|
|
44
|
+
export const ResourceStateSchema = Schema.Struct({
|
|
45
|
+
backfill: Schema.optional(BackfillStateSchema),
|
|
46
|
+
changes: Schema.optional(ChangesStateSchema),
|
|
47
|
+
lastError: Schema.optional(ResourceErrorSchema),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type PersistedBackfillState = Schema.Schema.Type<typeof BackfillStateSchema>;
|
|
51
|
+
export type PersistedChangesState = Schema.Schema.Type<typeof ChangesStateSchema>;
|
|
52
|
+
export type StateSource = Schema.Schema.Type<typeof StateSourceSchema>;
|
|
53
|
+
export type StateOperation = Schema.Schema.Type<typeof StateOperationSchema>;
|
|
54
|
+
export type StateErrorCode = Schema.Schema.Type<typeof StateErrorCodeSchema>;
|
|
55
|
+
export type ResourceErrorContext = Schema.Schema.Type<typeof ResourceErrorContextSchema>;
|
|
56
|
+
export type PersistedResourceError = Schema.Schema.Type<typeof ResourceErrorSchema>;
|
|
57
|
+
export type PersistedResourceState = Schema.Schema.Type<typeof ResourceStateSchema>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PersistedBackfillState,
|
|
5
|
+
PersistedChangesState,
|
|
6
|
+
PersistedResourceState,
|
|
7
|
+
StateOperation,
|
|
8
|
+
StateSource,
|
|
9
|
+
} from "./schema";
|
|
10
|
+
|
|
11
|
+
import { ConnectorError } from "../errors";
|
|
12
|
+
|
|
13
|
+
/** Connector-facing durable state operations, independent of the storage backend. */
|
|
14
|
+
export interface StateStoreService {
|
|
15
|
+
readonly getResourceState: (
|
|
16
|
+
resource: string,
|
|
17
|
+
) => Effect.Effect<PersistedResourceState | undefined, ConnectorError>;
|
|
18
|
+
readonly setBackfillState: (
|
|
19
|
+
resource: string,
|
|
20
|
+
state: PersistedBackfillState,
|
|
21
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
22
|
+
readonly setChangesState: (
|
|
23
|
+
resource: string,
|
|
24
|
+
state: PersistedChangesState,
|
|
25
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
26
|
+
readonly setResourceError: (
|
|
27
|
+
resource: string,
|
|
28
|
+
source: StateSource,
|
|
29
|
+
operation: StateOperation,
|
|
30
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
31
|
+
readonly clearResourceError: (
|
|
32
|
+
resource: string,
|
|
33
|
+
source: StateSource,
|
|
34
|
+
) => Effect.Effect<void, ConnectorError>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Effect service used by ingestion and status logic for connector checkpoints.
|
|
39
|
+
* Production provides `layerSql`; sandboxes and tests may provide `layerMemory`.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const program = Effect.gen(function* () {
|
|
44
|
+
* const state = yield* StateStore;
|
|
45
|
+
* yield* state.setChangesState("orders", { cursor: "next-page" });
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class StateStore extends Context.Service<StateStore, StateStoreService>()(
|
|
50
|
+
"@useairfoil/connector-kit/StateStore",
|
|
51
|
+
) {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Cursor, ResourceDefinition, ResourceState, SyncState } from "../core/types";
|
|
2
|
+
|
|
3
|
+
/** Normalizes a cursor value to its persisted/wire representation (never a Date). */
|
|
4
|
+
export const normalizeCursor = (value: Cursor.Value): string | number =>
|
|
5
|
+
value instanceof Date ? value.toISOString() : value;
|
|
6
|
+
|
|
7
|
+
/** Derives connector-facing sync state from durable state and resource capabilities. */
|
|
8
|
+
export const deriveSyncState = (
|
|
9
|
+
resource: ResourceDefinition,
|
|
10
|
+
state: ResourceState | undefined,
|
|
11
|
+
): SyncState => {
|
|
12
|
+
if (state?.lastError) return "error";
|
|
13
|
+
if (!resource.backfill && !resource.changes) return "live";
|
|
14
|
+
if (!state) return "pending";
|
|
15
|
+
if (resource.backfill && (!state.backfill || !state.backfill.completed)) return "backfilling";
|
|
16
|
+
return "live";
|
|
17
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Cause, Effect } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
|
|
4
|
+
import type { ConnectorDefinition } from "../core/types";
|
|
5
|
+
import type { ResourceStatus, StatusUnavailableResponse } from "./types";
|
|
6
|
+
|
|
7
|
+
import { StateStore } from "../state-store";
|
|
8
|
+
import { deriveSyncState, normalizeCursor } from "../state-store/state";
|
|
9
|
+
|
|
10
|
+
export const statusRoute = (
|
|
11
|
+
connector: ConnectorDefinition,
|
|
12
|
+
path: HttpRouter.PathInput = "/status",
|
|
13
|
+
) =>
|
|
14
|
+
HttpRouter.add(
|
|
15
|
+
"GET",
|
|
16
|
+
path,
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const store = yield* StateStore;
|
|
19
|
+
|
|
20
|
+
const resources = yield* Effect.forEach(
|
|
21
|
+
connector.resources,
|
|
22
|
+
(resource) =>
|
|
23
|
+
store.getResourceState(resource.name).pipe(
|
|
24
|
+
Effect.map(
|
|
25
|
+
(state): ResourceStatus => ({
|
|
26
|
+
name: resource.name,
|
|
27
|
+
state: deriveSyncState(resource, state),
|
|
28
|
+
...(state?.backfill
|
|
29
|
+
? {
|
|
30
|
+
backfill: {
|
|
31
|
+
completed: state.backfill.completed,
|
|
32
|
+
cutoff: normalizeCursor(state.backfill.cutoff),
|
|
33
|
+
...(state.backfill.pageCursor !== undefined
|
|
34
|
+
? { pageCursor: normalizeCursor(state.backfill.pageCursor) }
|
|
35
|
+
: {}),
|
|
36
|
+
...(state.backfill.lastSuccessAt !== undefined
|
|
37
|
+
? { lastSuccessAt: state.backfill.lastSuccessAt }
|
|
38
|
+
: {}),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
: {}),
|
|
42
|
+
...(state?.changes
|
|
43
|
+
? {
|
|
44
|
+
changes: {
|
|
45
|
+
cursor: normalizeCursor(state.changes.cursor),
|
|
46
|
+
...(state.changes.lastSuccessAt !== undefined
|
|
47
|
+
? { lastSuccessAt: state.changes.lastSuccessAt }
|
|
48
|
+
: {}),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
...(state?.lastError ? { lastError: state.lastError } : {}),
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
{ concurrency: "unbounded" },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return HttpServerResponse.jsonUnsafe({ connector: connector.name, resources });
|
|
60
|
+
}).pipe(
|
|
61
|
+
Effect.catchCause((cause) =>
|
|
62
|
+
Effect.logWarning(`Status route failed: ${Cause.pretty(cause)}`).pipe(
|
|
63
|
+
Effect.andThen(
|
|
64
|
+
Effect.succeed(
|
|
65
|
+
HttpServerResponse.jsonUnsafe(
|
|
66
|
+
{
|
|
67
|
+
ok: false,
|
|
68
|
+
error: "Failed to compute status",
|
|
69
|
+
} satisfies StatusUnavailableResponse,
|
|
70
|
+
{ status: 500 },
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ResourceErrorSchema as StateResourceErrorSchema,
|
|
5
|
+
ResourceStateSchema,
|
|
6
|
+
} from "../state-store/schema";
|
|
7
|
+
|
|
8
|
+
export const SyncStateSchema = Schema.Literals(["pending", "backfilling", "live", "error"]);
|
|
9
|
+
export type SyncState = Schema.Schema.Type<typeof SyncStateSchema>;
|
|
10
|
+
|
|
11
|
+
/** Safe source failure returned by the status endpoint. */
|
|
12
|
+
export const ResourceErrorSchema = StateResourceErrorSchema;
|
|
13
|
+
export type ResourceError = Schema.Schema.Type<typeof ResourceErrorSchema>;
|
|
14
|
+
|
|
15
|
+
export const ResourceStatusSchema = Schema.Struct({
|
|
16
|
+
name: Schema.String,
|
|
17
|
+
state: SyncStateSchema,
|
|
18
|
+
...ResourceStateSchema.fields,
|
|
19
|
+
});
|
|
20
|
+
export type ResourceStatus = Schema.Schema.Type<typeof ResourceStatusSchema>;
|
|
21
|
+
|
|
22
|
+
export const StatusResponseSchema = Schema.Struct({
|
|
23
|
+
connector: Schema.String,
|
|
24
|
+
resources: Schema.Array(ResourceStatusSchema),
|
|
25
|
+
});
|
|
26
|
+
export type StatusResponse = Schema.Schema.Type<typeof StatusResponseSchema>;
|
|
27
|
+
|
|
28
|
+
/** Returned when durable resource status cannot be read. */
|
|
29
|
+
export const StatusUnavailableResponseSchema = Schema.Struct({
|
|
30
|
+
ok: Schema.Literal(false),
|
|
31
|
+
error: Schema.String,
|
|
32
|
+
});
|
|
33
|
+
export type StatusUnavailableResponse = Schema.Schema.Type<typeof StatusUnavailableResponseSchema>;
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { Cause, Config, Effect, Layer, Metric, Option, type Duration } from "effect";
|
|
2
|
+
import { Headers } from "effect/unstable/http";
|
|
3
|
+
import * as Observability from "effect/unstable/observability";
|
|
4
|
+
|
|
5
|
+
import { PlatformRuntimeKey } from "./runtime-config/constants";
|
|
6
|
+
|
|
7
|
+
/** Canonical span names emitted by connector-kit runtime instrumentation. */
|
|
8
|
+
export const SpanName = {
|
|
9
|
+
batchProcess: "connector.batch.process",
|
|
10
|
+
webhookDecode: "connector.webhook.decode",
|
|
11
|
+
webhookHandle: "connector.webhook.handle",
|
|
12
|
+
publish: "connector.publish",
|
|
13
|
+
stateSet: "connector.state.set",
|
|
14
|
+
apiFetch: "connector.api.fetch",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** Canonical span attribute names. Keep these stable for collector queries. */
|
|
18
|
+
export const Attr = {
|
|
19
|
+
connectorName: "airfoil.connector.name",
|
|
20
|
+
connectorResourcesCount: "airfoil.connector.resources.count",
|
|
21
|
+
resourceName: "airfoil.resource.name",
|
|
22
|
+
resourceSource: "airfoil.resource.source",
|
|
23
|
+
batchMutations: "airfoil.batch.mutations",
|
|
24
|
+
webhookPath: "airfoil.webhook.path",
|
|
25
|
+
publisherSuccess: "airfoil.publisher.success",
|
|
26
|
+
stateKey: "airfoil.state.key",
|
|
27
|
+
apiPath: "airfoil.api.path",
|
|
28
|
+
errorPhase: "airfoil.error.phase",
|
|
29
|
+
errorType: "airfoil.error.type",
|
|
30
|
+
errorMessage: "airfoil.error.message",
|
|
31
|
+
errorDetails: "airfoil.error.details",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** Span event names for high-cardinality reproduction details. */
|
|
35
|
+
export const EventName = {
|
|
36
|
+
batchCheckpoint: "airfoil.batch.checkpoint",
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/** Event-only attributes. These are intentionally not span attributes. */
|
|
40
|
+
export const EventAttr = {
|
|
41
|
+
batchCursor: "airfoil.batch.cursor",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
45
|
+
typeof value === "object" && value !== null;
|
|
46
|
+
|
|
47
|
+
const maxErrorMessageLength = 500;
|
|
48
|
+
|
|
49
|
+
const OtlpEnvConfig = Config.all({
|
|
50
|
+
enabled: Config.boolean(PlatformRuntimeKey.otelEnabled).pipe(Config.withDefault(false)),
|
|
51
|
+
baseUrl: Config.option(Config.string(PlatformRuntimeKey.otelExporterOtlpEndpoint)),
|
|
52
|
+
rawHeaders: Config.option(Config.string(PlatformRuntimeKey.otelExporterOtlpHeaders)),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const defaultRedactedHeaders: ReadonlyArray<string | RegExp> = [
|
|
56
|
+
"authorization",
|
|
57
|
+
"cookie",
|
|
58
|
+
"set-cookie",
|
|
59
|
+
"x-api-key",
|
|
60
|
+
/api[-_]?key/i,
|
|
61
|
+
/secret/i,
|
|
62
|
+
/signature/i,
|
|
63
|
+
/token/i,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const parseOtelHeaders = (value: string): Record<string, string> =>
|
|
67
|
+
Object.fromEntries(
|
|
68
|
+
value
|
|
69
|
+
.split(",")
|
|
70
|
+
.map((entry) => entry.trim())
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.flatMap((entry) => {
|
|
73
|
+
const separator = entry.indexOf("=");
|
|
74
|
+
if (separator < 1) return [];
|
|
75
|
+
return [[entry.slice(0, separator).trim(), entry.slice(separator + 1).trim()]];
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const appendPath = (baseUrl: string, path: string) =>
|
|
80
|
+
`${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Best-effort stable error classifier for span metadata.
|
|
84
|
+
*
|
|
85
|
+
* Effect/Data tagged errors expose `_tag`; that is the most useful value for
|
|
86
|
+
* connector bugs (`ConnectorError`, `InvalidWebhookPayloadError`, etc.). For
|
|
87
|
+
* non-tagged errors we fall back to the JS `Error.name`, then constructor name,
|
|
88
|
+
* then the primitive type.
|
|
89
|
+
*/
|
|
90
|
+
const errorType = (error: unknown) => {
|
|
91
|
+
if (isRecord(error) && typeof error._tag === "string") {
|
|
92
|
+
return String(error._tag);
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof Error) {
|
|
95
|
+
return error.name;
|
|
96
|
+
}
|
|
97
|
+
if (isRecord(error) && typeof error.constructor?.name === "string") {
|
|
98
|
+
return error.constructor.name;
|
|
99
|
+
}
|
|
100
|
+
return typeof error;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Human-readable error summary for span metadata.
|
|
105
|
+
*
|
|
106
|
+
* Tagged errors in this package carry a `message` field, so this preserves the
|
|
107
|
+
* important diagnostic text. Unknown object failures are intentionally reduced
|
|
108
|
+
* to their type; full structured error payloads belong in logs, not span attrs.
|
|
109
|
+
*/
|
|
110
|
+
const errorMessage = (error: unknown) => {
|
|
111
|
+
const message = (() => {
|
|
112
|
+
if (isRecord(error) && typeof error.message === "string") {
|
|
113
|
+
return error.message;
|
|
114
|
+
}
|
|
115
|
+
if (typeof error === "string") {
|
|
116
|
+
return error;
|
|
117
|
+
}
|
|
118
|
+
return errorType(error);
|
|
119
|
+
})();
|
|
120
|
+
|
|
121
|
+
if (message.length > maxErrorMessageLength) {
|
|
122
|
+
return `${message.slice(0, maxErrorMessageLength)}...`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return message;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const errorDetails = (error: unknown): string | undefined => {
|
|
129
|
+
if (!isRecord(error) || error.cause === undefined) return undefined;
|
|
130
|
+
return Cause.pretty(Cause.die(error.cause));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Annotates the active span with phase-aware error metadata before re-failing. */
|
|
134
|
+
export const annotateError = (phase: string, error: unknown) => {
|
|
135
|
+
const details = errorDetails(error);
|
|
136
|
+
return Effect.annotateCurrentSpan({
|
|
137
|
+
[Attr.errorPhase]: phase,
|
|
138
|
+
[Attr.errorType]: errorType(error),
|
|
139
|
+
[Attr.errorMessage]: errorMessage(error),
|
|
140
|
+
...(details !== undefined ? { [Attr.errorDetails]: details } : {}),
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Adds an event to the active span, or no-ops when no span is active. */
|
|
145
|
+
export const addCurrentSpanEvent = (name: string, attributes?: Record<string, unknown>) =>
|
|
146
|
+
Effect.currentSpan.pipe(
|
|
147
|
+
Effect.flatMap((span) =>
|
|
148
|
+
Effect.clockWith((clock) =>
|
|
149
|
+
Effect.sync(() => span.event(name, clock.currentTimeNanosUnsafe(), attributes)),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
Effect.ignore,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
/** Transport configuration for OTLP trace export. All fields are direct values. */
|
|
156
|
+
export type OtlpTracingConfig = {
|
|
157
|
+
readonly enabled?: boolean;
|
|
158
|
+
readonly endpoint?: string;
|
|
159
|
+
readonly headers?: Record<string, string>;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Runtime options — not config-wrappable since RegExp cannot live in Config. */
|
|
163
|
+
export type OtlpTracingOptions = {
|
|
164
|
+
/** Additional connector-specific header names or regexes to redact from HTTP logs/traces. */
|
|
165
|
+
readonly redactedHeaders?: ReadonlyArray<string | RegExp>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type OtlpMetricsOptions = {
|
|
169
|
+
readonly exportInterval?: Duration.Input;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const buildOtlpTracingLayer = (config: OtlpTracingConfig, options: OtlpTracingOptions) =>
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const allRedacted = [...defaultRedactedHeaders, ...(options.redactedHeaders ?? [])];
|
|
175
|
+
const RedactionLayer = Layer.succeed(Headers.CurrentRedactedNames)(allRedacted);
|
|
176
|
+
|
|
177
|
+
if (!config.enabled) {
|
|
178
|
+
return RedactionLayer;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!config.endpoint) {
|
|
182
|
+
return yield* Effect.fail(new Error("enabled=true requires endpoint"));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
yield* Effect.logInfo("telemetry enabled").pipe(
|
|
186
|
+
Effect.annotateLogs({
|
|
187
|
+
endpoint: config.endpoint,
|
|
188
|
+
headers: config.headers ? Object.keys(config.headers) : [],
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return Layer.mergeAll(
|
|
193
|
+
RedactionLayer,
|
|
194
|
+
Observability.OtlpTracer.layer({
|
|
195
|
+
url: appendPath(config.endpoint, "/v1/traces"),
|
|
196
|
+
headers: config.headers,
|
|
197
|
+
}).pipe(Layer.provide(Observability.OtlpSerialization.layerJson)),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Trace-only OTLP export with sensitive HTTP header redaction.
|
|
203
|
+
* Takes direct config values — no `ConfigProvider` required.
|
|
204
|
+
*/
|
|
205
|
+
export const layer = (config: OtlpTracingConfig = {}, options: OtlpTracingOptions = {}) =>
|
|
206
|
+
Layer.unwrap(buildOtlpTracingLayer(config, options));
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Trace-only OTLP export with sensitive HTTP header redaction.
|
|
210
|
+
* Takes Effect Config-wrapped values — reads from `ConfigProvider`.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* Telemetry.layerConfig({
|
|
214
|
+
* enabled: Config.boolean("MY_OTEL_ENABLED"),
|
|
215
|
+
* endpoint: Config.string("MY_COLLECTOR_URL"),
|
|
216
|
+
* })
|
|
217
|
+
*/
|
|
218
|
+
export const layerConfig = (
|
|
219
|
+
config: Config.Wrap<OtlpTracingConfig>,
|
|
220
|
+
options: OtlpTracingOptions = {},
|
|
221
|
+
) =>
|
|
222
|
+
Layer.unwrap(
|
|
223
|
+
Effect.gen(function* () {
|
|
224
|
+
const resolved = yield* Config.unwrap(config);
|
|
225
|
+
return yield* buildOtlpTracingLayer(resolved, options);
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Trace-only OTLP export with sensitive HTTP header redaction.
|
|
231
|
+
* Zero-config shortcut that reads the standard `OTEL_*` env var names.
|
|
232
|
+
*
|
|
233
|
+
* Reads `OTEL_ENABLED`, `OTEL_EXPORTER_OTLP_ENDPOINT`, and
|
|
234
|
+
* `OTEL_EXPORTER_OTLP_HEADERS`. Effect's OTLP resource handling reads
|
|
235
|
+
* `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, and `OTEL_RESOURCE_ATTRIBUTES`.
|
|
236
|
+
*/
|
|
237
|
+
export const layerOtlpTracing = (options: OtlpTracingOptions = {}) =>
|
|
238
|
+
Layer.unwrap(
|
|
239
|
+
Effect.gen(function* () {
|
|
240
|
+
const raw = yield* OtlpEnvConfig;
|
|
241
|
+
return yield* buildOtlpTracingLayer(
|
|
242
|
+
{
|
|
243
|
+
enabled: raw.enabled,
|
|
244
|
+
endpoint: Option.getOrUndefined(raw.baseUrl),
|
|
245
|
+
headers: Option.isSome(raw.rawHeaders)
|
|
246
|
+
? parseOtelHeaders(raw.rawHeaders.value)
|
|
247
|
+
: undefined,
|
|
248
|
+
},
|
|
249
|
+
options,
|
|
250
|
+
);
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
export const layerOtlpMetrics = (options: OtlpMetricsOptions = {}) =>
|
|
255
|
+
Layer.unwrap(
|
|
256
|
+
Effect.gen(function* () {
|
|
257
|
+
const raw = yield* OtlpEnvConfig;
|
|
258
|
+
if (!raw.enabled) return Layer.empty;
|
|
259
|
+
const endpoint = Option.getOrUndefined(raw.baseUrl);
|
|
260
|
+
if (!endpoint)
|
|
261
|
+
return yield* Effect.fail(
|
|
262
|
+
new Error("OTEL_ENABLED=true requires OTEL_EXPORTER_OTLP_ENDPOINT"),
|
|
263
|
+
);
|
|
264
|
+
return Observability.OtlpMetrics.layer({
|
|
265
|
+
url: appendPath(endpoint, "/v1/metrics"),
|
|
266
|
+
headers: Option.isSome(raw.rawHeaders) ? parseOtelHeaders(raw.rawHeaders.value) : undefined,
|
|
267
|
+
exportInterval: options.exportInterval,
|
|
268
|
+
temporality: "cumulative",
|
|
269
|
+
}).pipe(Layer.provide(Observability.OtlpSerialization.layerJson));
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
export const layerOtlp = (options: OtlpTracingOptions & OtlpMetricsOptions = {}) =>
|
|
274
|
+
Layer.mergeAll(layerOtlpTracing(options), layerOtlpMetrics(options));
|
|
275
|
+
|
|
276
|
+
export const layerMetricsConsoleDump = (interval: Duration.Input = "30 seconds") =>
|
|
277
|
+
Layer.effectDiscard(
|
|
278
|
+
Effect.gen(function* () {
|
|
279
|
+
yield* Effect.forkScoped(
|
|
280
|
+
Effect.forever(
|
|
281
|
+
Metric.dump.pipe(
|
|
282
|
+
Effect.flatMap((snapshot) => Effect.logInfo(snapshot)),
|
|
283
|
+
Effect.andThen(Effect.sleep(interval)),
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
@@ -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;
|