UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

258 lines (257 loc) • 9.6 kB
const { Temporal } = require("@js-temporal/polyfill"); const { URLPattern } = require("urlpattern-polyfill"); Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); require("../chunk-DDcVe30Y.cjs"); let _logtape_logtape = require("@logtape/logtape"); let _opentelemetry_core = require("@opentelemetry/core"); //#region src/otel/exporter.ts /** * A SpanExporter that persists ActivityPub activity traces to a * {@link KvStore}. This enables distributed tracing across multiple * nodes in a Fedify deployment. * * The exporter captures activity data from OpenTelemetry span events * (`activitypub.activity.received` and `activitypub.activity.sent`) * and stores them in the KvStore with trace context preserved. * * @example Basic usage with MemoryKvStore * ```typescript ignore * import { MemoryKvStore } from "@fedify/fedify"; * import { FedifySpanExporter } from "@fedify/fedify/otel"; * import { * BasicTracerProvider, * SimpleSpanProcessor, * } from "@opentelemetry/sdk-trace-base"; * * const kv = new MemoryKvStore(); * const exporter = new FedifySpanExporter(kv, { * ttl: Temporal.Duration.from({ hours: 1 }), * }); * * const provider = new BasicTracerProvider({ * spanProcessors: [new SimpleSpanProcessor(exporter)], * }); * ``` * * @example Querying stored traces * ```typescript ignore * import { MemoryKvStore } from "@fedify/fedify"; * import { FedifySpanExporter } from "@fedify/fedify/otel"; * * const kv = new MemoryKvStore(); * const exporter = new FedifySpanExporter(kv); * const traceId = "abc123"; * * // Get all activities for a specific trace * const activities = await exporter.getActivitiesByTraceId(traceId); * * // Get recent traces * const recentTraces = await exporter.getRecentTraces({ limit: 100 }); * ``` * * @since 1.10.0 */ var FedifySpanExporter = class { #kv; #ttl; #keyPrefix; /** * Creates a new FedifySpanExporter. * * @param kv The KvStore to persist trace data to. * @param options Configuration options. */ constructor(kv, options) { this.#kv = kv; this.#ttl = options?.ttl; this.#keyPrefix = options?.keyPrefix ?? ["fedify", "traces"]; } /** * Exports spans to the KvStore. * * @param spans The spans to export. * @param resultCallback Callback to invoke with the export result. */ export(spans, resultCallback) { this.#exportAsync(spans).then(() => resultCallback({ code: _opentelemetry_core.ExportResultCode.SUCCESS })).catch((error) => { (0, _logtape_logtape.getLogger)([ "fedify", "otel", "exporter" ]).error("Failed to export spans to KvStore: {error}", { error }); resultCallback({ code: _opentelemetry_core.ExportResultCode.FAILED }); }); } async #exportAsync(spans) { const storeOperations = []; for (const span of spans) { const records = this.#extractRecords(span); for (const record of records) storeOperations.push(this.#storeRecord(record)); } const rejected = (await Promise.allSettled(storeOperations)).filter((r) => r.status === "rejected"); if (rejected.length > 0) throw new AggregateError(rejected.map((r) => r.reason), "Failed to store one or more trace activity records."); } #extractRecords(span) { const records = []; const spanContext = span.spanContext(); const traceId = spanContext.traceId; const spanId = spanContext.spanId; const parentSpanId = span.parentSpanContext?.spanId; for (const event of span.events) if (event.name === "activitypub.activity.received") { const record = this.#extractInboundRecord(event, traceId, spanId, parentSpanId); if (record != null) records.push(record); } else if (event.name === "activitypub.activity.sent") { const record = this.#extractOutboundRecord(event, traceId, spanId, parentSpanId); if (record != null) records.push(record); } return records; } #extractInboundRecord(event, traceId, spanId, parentSpanId) { const attrs = event.attributes; if (attrs == null) return null; const activityJson = attrs["activitypub.activity.json"]; if (typeof activityJson !== "string") return null; let activityType = "Unknown"; let activityId; let actorId; try { const activity = JSON.parse(activityJson); activityType = activity.type ?? "Unknown"; activityId = activity.id; if (typeof activity.actor === "string") actorId = activity.actor; else if (activity.actor != null && typeof activity.actor.id === "string") actorId = activity.actor.id; } catch {} const verified = attrs["activitypub.activity.verified"]; const httpSigVerified = attrs["http_signatures.verified"]; const httpSigKeyId = attrs["http_signatures.key_id"]; const httpSigFailureReason = attrs["http_signatures.failure_reason"]; const httpSigKeyFetchStatus = attrs["http_signatures.key_fetch_status"]; const httpSigKeyFetchError = attrs["http_signatures.key_fetch_error"]; const ldSigVerified = attrs["ld_signatures.verified"]; let signatureDetails; if (typeof httpSigVerified === "boolean" || typeof ldSigVerified === "boolean" || typeof httpSigFailureReason === "string") signatureDetails = { httpSignaturesVerified: httpSigVerified === true, httpSignaturesKeyId: typeof httpSigKeyId === "string" && httpSigKeyId !== "" ? httpSigKeyId : void 0, httpSignaturesFailureReason: typeof httpSigFailureReason === "string" && httpSigFailureReason !== "" ? httpSigFailureReason : void 0, httpSignaturesKeyFetchStatus: typeof httpSigKeyFetchStatus === "number" ? httpSigKeyFetchStatus : void 0, httpSignaturesKeyFetchError: typeof httpSigKeyFetchError === "string" && httpSigKeyFetchError !== "" ? httpSigKeyFetchError : void 0, ldSignaturesVerified: ldSigVerified === true }; return { traceId, spanId, parentSpanId, direction: "inbound", activityType, activityId, actorId, activityJson, verified: typeof verified === "boolean" ? verified : void 0, signatureDetails, timestamp: (/* @__PURE__ */ new Date(event.time[0] * 1e3 + event.time[1] / 1e6)).toISOString() }; } #extractOutboundRecord(event, traceId, spanId, parentSpanId) { const attrs = event.attributes; if (attrs == null) return null; const activityJson = attrs["activitypub.activity.json"]; if (typeof activityJson !== "string") return null; let activityType = "Unknown"; let activityId; let actorId; try { const activity = JSON.parse(activityJson); activityType = activity.type ?? "Unknown"; activityId = activity.id; if (typeof activity.actor === "string") actorId = activity.actor; else if (activity.actor != null && typeof activity.actor.id === "string") actorId = activity.actor.id; } catch {} const inboxUrl = attrs["activitypub.inbox.url"]; const explicitActivityId = attrs["activitypub.activity.id"]; return { traceId, spanId, parentSpanId, direction: "outbound", activityType, activityId: activityId ?? (typeof explicitActivityId === "string" && explicitActivityId !== "" ? explicitActivityId : void 0), actorId, activityJson, timestamp: (/* @__PURE__ */ new Date(event.time[0] * 1e3 + event.time[1] / 1e6)).toISOString(), inboxUrl: typeof inboxUrl === "string" ? inboxUrl : void 0 }; } async #storeRecord(record) { const options = this.#ttl != null ? { ttl: this.#ttl } : void 0; const key = [ ...this.#keyPrefix, record.traceId, record.spanId ]; await this.#kv.set(key, record, options); await this.#updateTraceSummary(record, options); } async #setWithCasRetry(key, transform, options) { if (this.#kv.cas != null) for (let attempt = 0; attempt < 3; attempt++) { const existing = await this.#kv.get(key); const newValue = transform(existing); if (await this.#kv.cas(key, existing, newValue, options)) return; } const newValue = transform(await this.#kv.get(key)); await this.#kv.set(key, newValue, options); } async #updateTraceSummary(record, options) { const summaryKey = [ ...this.#keyPrefix, "_summaries", record.traceId ]; await this.#setWithCasRetry(summaryKey, (existing) => { const activityCount = existing != null ? existing.activityCount + 1 : 1; const activityTypes = existing != null ? existing.activityTypes.includes(record.activityType) ? existing.activityTypes : [...existing.activityTypes, record.activityType] : [record.activityType]; return { traceId: existing?.traceId ?? record.traceId, timestamp: existing?.timestamp ?? record.timestamp, activityCount, activityTypes }; }, options); } /** * Gets all activity records for a specific trace ID. * * @param traceId The trace ID to query. * @returns An array of activity records belonging to the trace. */ async getActivitiesByTraceId(traceId) { const prefix = [...this.#keyPrefix, traceId]; const records = []; for await (const entry of this.#kv.list(prefix)) records.push(entry.value); return records; } /** * Gets recent traces with summary information. * * @param options Options for the query. * @returns An array of trace summaries. */ async getRecentTraces(options) { const summaryPrefix = [...this.#keyPrefix, "_summaries"]; const summaries = []; for await (const entry of this.#kv.list(summaryPrefix)) summaries.push(entry.value); summaries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); if (options?.limit != null) return summaries.slice(0, options.limit); return summaries; } /** * Forces the exporter to flush any buffered data. * This is a no-op because we write directly to the KvStore without buffering. */ async forceFlush() {} /** * Shuts down the exporter. */ async shutdown() {} }; //#endregion exports.FedifySpanExporter = FedifySpanExporter;