UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

881 lines (880 loc) • 31.6 kB
import { Temporal } from "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { t as assertEquals } from "../assert_equals-Ew3jOFa3.mjs"; import "../std__assert-CRDpx_HF.mjs"; import { t as MemoryKvStore } from "../kv-rV3vodCc.mjs"; import { test } from "@fedify/fixture"; import { SpanKind, SpanStatusCode, TraceFlags } from "@opentelemetry/api"; import { getLogger } from "@logtape/logtape"; import { ExportResultCode } from "@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: ExportResultCode.SUCCESS })).catch((error) => { getLogger([ "fedify", "otel", "exporter" ]).error("Failed to export spans to KvStore: {error}", { error }); resultCallback({ code: 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 //#region src/otel/exporter.test.ts function createMockSpan(options) { const traceId = options.traceId ?? "0123456789abcdef0123456789abcdef"; const spanContext = { traceId, spanId: options.spanId ?? "0123456789abcdef", traceFlags: TraceFlags.SAMPLED }; const parentSpanContext = options.parentSpanId ? { traceId, spanId: options.parentSpanId, traceFlags: TraceFlags.SAMPLED } : void 0; return { name: options.name ?? "test-span", kind: SpanKind.INTERNAL, spanContext: () => spanContext, parentSpanContext, startTime: [17e8, 0], endTime: [1700000001, 0], status: { code: SpanStatusCode.OK }, attributes: {}, links: [], events: options.events ?? [], duration: [1, 0], ended: true, resource: { attributes: {}, getRawAttributes: () => [], merge: () => ({ attributes: {}, getRawAttributes: () => [], merge: () => null }) }, instrumentationScope: { name: "test" }, droppedAttributesCount: 0, droppedEventsCount: 0, droppedLinksCount: 0 }; } function createActivityReceivedEvent(options) { return { name: "activitypub.activity.received", time: [17e8, 5e8], attributes: { "activitypub.activity.json": options.activityJson, "activitypub.activity.verified": options.verified ?? true, "ld_signatures.verified": options.ldSigVerified ?? false, "http_signatures.verified": options.httpSigVerified ?? true, "http_signatures.key_id": options.httpSigKeyId ?? "", "http_signatures.failure_reason": options.httpSigFailureReason ?? "", "http_signatures.key_fetch_status": options.httpSigKeyFetchStatus, "http_signatures.key_fetch_error": options.httpSigKeyFetchError ?? "" } }; } function createActivitySentEvent(options) { return { name: "activitypub.activity.sent", time: [17e8, 5e8], attributes: { "activitypub.activity.json": options.activityJson, "activitypub.inbox.url": options.inboxUrl, "activitypub.activity.id": options.activityId ?? "" } }; } test("FedifySpanExporter", async (t) => { await t.step("constructor accepts KvStore with list()", () => { assertEquals(new FedifySpanExporter(new MemoryKvStore()) instanceof FedifySpanExporter, true); }); await t.step("export() stores inbound activity from span event", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const traceId = "trace123"; const spanId = "span456"; const activity = { "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: "https://example.com/activities/123", actor: "https://example.com/users/alice", object: { type: "Note", content: "Hello!" } }; const activityJson = JSON.stringify(activity); const span = createMockSpan({ traceId, spanId, name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson, verified: true, httpSigVerified: true })] }); await new Promise((resolve) => { exporter.export([span], (result) => { assertEquals(result.code, 0); resolve(); }); }); const activities = await exporter.getActivitiesByTraceId(traceId); assertEquals(activities.length, 1); assertEquals(activities[0].traceId, traceId); assertEquals(activities[0].spanId, spanId); assertEquals(activities[0].direction, "inbound"); assertEquals(activities[0].activityType, activity.type); assertEquals(activities[0].activityId, activity.id); assertEquals(activities[0].activityJson, activityJson); assertEquals(activities[0].verified, true); }); await t.step("export() stores outbound activity from span event", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const traceId = "trace789"; const spanId = "span012"; const inboxUrl = "https://example.com/users/alice/inbox"; const activity = { "@context": "https://www.w3.org/ns/activitystreams", type: "Follow", id: "https://myserver.com/activities/789", actor: "https://myserver.com/users/bob", object: "https://example.com/users/alice" }; const span = createMockSpan({ traceId, spanId, name: "activitypub.send_activity", events: [createActivitySentEvent({ activityJson: JSON.stringify(activity), inboxUrl, activityId: activity.id })] }); await new Promise((resolve) => { exporter.export([span], (result) => { assertEquals(result.code, 0); resolve(); }); }); const activities = await exporter.getActivitiesByTraceId(traceId); assertEquals(activities.length, 1); assertEquals(activities[0].traceId, traceId); assertEquals(activities[0].spanId, spanId); assertEquals(activities[0].direction, "outbound"); assertEquals(activities[0].activityType, activity.type); assertEquals(activities[0].activityId, activity.id); assertEquals(activities[0].inboxUrl, inboxUrl); }); await t.step("export() ignores spans without activity events", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "trace999", spanId: "span999", name: "some-other-span", events: [] }); await new Promise((resolve) => { exporter.export([span], (result) => { assertEquals(result.code, 0); resolve(); }); }); assertEquals((await exporter.getActivitiesByTraceId("trace999")).length, 0); }); await t.step("export() stores multiple activities from same trace", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const inboundActivity = JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: "https://example.com/activities/1" }); const outboundActivity = JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Accept", id: "https://myserver.com/activities/2" }); const span1 = createMockSpan({ traceId: "multitrace", spanId: "span1", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: inboundActivity })] }); const span2 = createMockSpan({ traceId: "multitrace", spanId: "span2", parentSpanId: "span1", name: "activitypub.send_activity", events: [createActivitySentEvent({ activityJson: outboundActivity, inboxUrl: "https://example.com/inbox" })] }); await new Promise((resolve) => { exporter.export([span1, span2], (result) => { assertEquals(result.code, 0); resolve(); }); }); const activities = await exporter.getActivitiesByTraceId("multitrace"); assertEquals(activities.length, 2); const inbound = activities.find((a) => a.direction === "inbound"); const outbound = activities.find((a) => a.direction === "outbound"); assertEquals(inbound?.activityType, "Create"); assertEquals(outbound?.activityType, "Accept"); assertEquals(outbound?.parentSpanId, "span1"); }); await t.step("getRecentTraces() returns recent traces", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); for (let i = 0; i < 5; i++) { const activityJson = JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: `https://example.com/activities/${i}` }); const span = createMockSpan({ traceId: `trace-${i}`, spanId: `span-${i}`, name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); } assertEquals((await exporter.getRecentTraces({ limit: 3 })).length, 3); }); await t.step("getRecentTraces() returns all traces when limit not specified", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); for (let i = 0; i < 3; i++) { const activityJson = JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: `https://example.com/activities/${i}` }); const span = createMockSpan({ traceId: `all-trace-${i}`, spanId: `span-${i}`, name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); } assertEquals((await exporter.getRecentTraces()).length >= 3, true); }); await t.step("forceFlush() returns resolved promise", async () => { await new FedifySpanExporter(new MemoryKvStore()).forceFlush(); }); await t.step("shutdown() completes successfully", async () => { await new FedifySpanExporter(new MemoryKvStore()).shutdown(); }); await t.step("works with custom KvStore implementation", async () => { const storedData = {}; const exporter = new FedifySpanExporter({ get: (key) => { const k = JSON.stringify(key); return Promise.resolve(storedData[k]); }, set: (key, value) => { const k = JSON.stringify(key); storedData[k] = value; return Promise.resolve(); }, delete: (key) => { const k = JSON.stringify(key); delete storedData[k]; return Promise.resolve(); }, async *list(prefix) { for (const [encodedKey, value] of Object.entries(storedData)) { const key = JSON.parse(encodedKey); if (prefix != null) { if (key.length < prefix.length) continue; if (!prefix.every((p, i) => key[i] === p)) continue; } yield { key, value }; } } }); const span = createMockSpan({ traceId: "cas-trace", spanId: "cas-span", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Like", id: "https://example.com/activities/like" }) })] }); await new Promise((resolve) => { exporter.export([span], (result) => { assertEquals(result.code, 0); resolve(); }); }); const activities = await exporter.getActivitiesByTraceId("cas-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].activityType, "Like"); }); await t.step("TTL option is respected", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore(), { ttl: Temporal.Duration.from({ hours: 1 }) }); const span = createMockSpan({ traceId: "ttl-trace", spanId: "ttl-span", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create" }) })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); assertEquals((await exporter.getActivitiesByTraceId("ttl-trace")).length, 1); }); await t.step("keyPrefix option customizes storage keys", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore(), { keyPrefix: ["custom", "prefix"] }); const span = createMockSpan({ traceId: "prefix-trace", spanId: "prefix-span", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Announce" }) })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("prefix-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].activityType, "Announce"); }); await t.step("separate exporter instances share state via same KvStore (distributed simulation)", async () => { const sharedKv = new MemoryKvStore(); const webServerExporter = new FedifySpanExporter(sharedKv); const workerExporter = new FedifySpanExporter(sharedKv); const dashboardExporter = new FedifySpanExporter(sharedKv); const inboxSpan = createMockSpan({ traceId: "distributed-trace-001", spanId: "inbox-span", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Follow", id: "https://remote.example/activities/follow-1", actor: "https://remote.example/users/alice", object: "https://local.example/users/bob" }), verified: true })] }); await new Promise((resolve) => { webServerExporter.export([inboxSpan], () => resolve()); }); const sendSpan = createMockSpan({ traceId: "distributed-trace-001", spanId: "send-span", parentSpanId: "inbox-span", name: "activitypub.send_activity", events: [createActivitySentEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Accept", id: "https://local.example/activities/accept-1", actor: "https://local.example/users/bob", object: "https://remote.example/activities/follow-1" }), inboxUrl: "https://remote.example/users/alice/inbox", activityId: "https://local.example/activities/accept-1" })] }); await new Promise((resolve) => { workerExporter.export([sendSpan], () => resolve()); }); const activities = await dashboardExporter.getActivitiesByTraceId("distributed-trace-001"); assertEquals(activities.length, 2); const follow = activities.find((a) => a.activityType === "Follow"); const accept = activities.find((a) => a.activityType === "Accept"); assertEquals(follow != null, true); assertEquals(follow?.direction, "inbound"); assertEquals(follow?.verified, true); assertEquals(accept != null, true); assertEquals(accept?.direction, "outbound"); assertEquals(accept?.inboxUrl, "https://remote.example/users/alice/inbox"); assertEquals(accept?.parentSpanId, "inbox-span"); const ourTrace = (await dashboardExporter.getRecentTraces()).find((t) => t.traceId === "distributed-trace-001"); assertEquals(ourTrace != null, true); assertEquals(ourTrace?.activityCount, 2); assertEquals(ourTrace?.activityTypes.includes("Follow"), true); assertEquals(ourTrace?.activityTypes.includes("Accept"), true); }); await t.step("multiple workers writing to same trace concurrently", async () => { const sharedKv = new MemoryKvStore(); const workers = [ new FedifySpanExporter(sharedKv), new FedifySpanExporter(sharedKv), new FedifySpanExporter(sharedKv) ]; const traceId = "concurrent-fanout-trace"; const exportPromises = workers.map((worker, i) => { const activityJson = JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: "https://local.example/activities/post-1" }); const span = createMockSpan({ traceId, spanId: `worker-${i}-span`, name: "activitypub.send_activity", events: [createActivitySentEvent({ activityJson, inboxUrl: `https://follower-${i}.example/inbox` })] }); return new Promise((resolve) => { worker.export([span], () => resolve()); }); }); await Promise.all(exportPromises); const activities = await new FedifySpanExporter(sharedKv).getActivitiesByTraceId(traceId); assertEquals(activities.length, 3); assertEquals(activities.map((a) => a.inboxUrl).sort(), [ "https://follower-0.example/inbox", "https://follower-1.example/inbox", "https://follower-2.example/inbox" ]); }); await t.step("extracts actorId from activity with string actor", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "actor-string-trace", spanId: "span1", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: "https://example.com/activities/123", actor: "https://example.com/users/alice", object: { type: "Note", content: "Hello!" } }) })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("actor-string-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].actorId, "https://example.com/users/alice"); }); await t.step("extracts actorId from activity with object actor", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "actor-object-trace", spanId: "span1", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: "https://example.com/activities/456", actor: { type: "Person", id: "https://example.com/users/bob", name: "Bob" }, object: { type: "Note", content: "Hello!" } }) })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("actor-object-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].actorId, "https://example.com/users/bob"); }); await t.step("extracts actorId from outbound activity", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "outbound-actor-trace", spanId: "span1", name: "activitypub.send_activity", events: [createActivitySentEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Follow", id: "https://myserver.com/activities/789", actor: "https://myserver.com/users/charlie", object: "https://example.com/users/alice" }), inboxUrl: "https://example.com/users/alice/inbox" })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("outbound-actor-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].actorId, "https://myserver.com/users/charlie"); }); await t.step("extracts signature verification details for inbound activity", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "sig-details-trace", spanId: "span1", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", id: "https://example.com/activities/sig-test", actor: "https://example.com/users/alice" }), verified: true, httpSigVerified: true, httpSigKeyId: "https://example.com/users/alice#main-key", ldSigVerified: false })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("sig-details-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].verified, true); assertEquals(activities[0].signatureDetails != null, true); assertEquals(activities[0].signatureDetails?.httpSignaturesVerified, true); assertEquals(activities[0].signatureDetails?.httpSignaturesKeyId, "https://example.com/users/alice#main-key"); assertEquals(activities[0].signatureDetails?.ldSignaturesVerified, false); }); await t.step("signature details with LD signatures verified", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "ld-sig-trace", spanId: "span1", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Delete", id: "https://example.com/activities/ld-sig-test", actor: "https://example.com/users/alice" }), verified: true, httpSigVerified: false, ldSigVerified: true })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("ld-sig-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].signatureDetails?.httpSignaturesVerified, false); assertEquals(activities[0].signatureDetails?.ldSignaturesVerified, true); }); await t.step("extracts HTTP signature failure details for inbound activity", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "sig-failure-trace", spanId: "span1", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Delete", id: "https://example.com/activities/unverified", actor: "https://example.com/users/alice" }), verified: false, httpSigVerified: false, httpSigKeyId: "https://example.com/users/alice#main-key", httpSigFailureReason: "keyFetchError", httpSigKeyFetchStatus: 410, ldSigVerified: false })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("sig-failure-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].signatureDetails?.httpSignaturesFailureReason, "keyFetchError"); assertEquals(activities[0].signatureDetails?.httpSignaturesKeyFetchStatus, 410); assertEquals(activities[0].signatureDetails?.httpSignaturesKeyFetchError, void 0); }); await t.step("handles activity without actor field", async () => { const exporter = new FedifySpanExporter(new MemoryKvStore()); const span = createMockSpan({ traceId: "no-actor-trace", spanId: "span1", name: "activitypub.inbox", events: [createActivityReceivedEvent({ activityJson: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Delete", id: "https://example.com/activities/no-actor", object: "https://example.com/posts/123" }) })] }); await new Promise((resolve) => { exporter.export([span], () => resolve()); }); const activities = await exporter.getActivitiesByTraceId("no-actor-trace"); assertEquals(activities.length, 1); assertEquals(activities[0].actorId, void 0); }); }); //#endregion export {};