@fedify/fedify
Version:
An ActivityPub server framework
881 lines (880 loc) • 31.6 kB
JavaScript
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 {};