Skip to main content

Architecture: Activity Log & Audit Infrastructure

Status: Active Last updated: 2026-05-01 Audience: AI agents and engineers debugging "why didn't this event get logged?", adding new activity types, or wiring downstream analytics consumers.


1. Purpose

The activity log is the platform's audit trail — every work mutation, generation run, and notable user action lands as a row in activity_log (per-action) and/or as a structured changelog attachment on work_generation_history (per-mutation audit detail).

This spec covers how those two stores relate, the synchronous write path every mutation goes through, the async analytics dispatcher that fans out to PostHog without blocking the request, and the activity-type taxonomy that drives the History tab and the work-changelog filter UI.

2. Two Stores, Different Jobs

StoreGranularityDrives
activity_logPer user-actionActivity feed, security audit, PostHog
work_generation_historyPer work mutationHistory tab, schedule failure tracking, generation metrics

work_generation_history rows can carry a changelog jsonb payload — see features/work-changelog/spec for the user-facing shape. activity_log rows summarise what the user did; history rows summarise what changed in the work. The two coexist by design — a single AI generation run produces one history row plus one work_generation_completed activity-log row, plus item-level changelog entries on the history row.

3. The ActivityLogService

Every mutation that needs to be audited goes through ActivityLogService.recordActivity(...):

await activityLogService.recordActivity({
userId,
workId,
actionType: ActivityActionType.WORK_GENERATED,
action: 'generation_completed',
status: ActivityStatus.COMPLETED,
summary: `Generated 27 items for "${work.name}"`,
details: { runId, itemCount, durationMs },
metadata: { triggerSource: 'schedule' },
ipAddress,
userAgent
});

The service:

  1. Builds the canonical row (ActivityLog entity).
  2. Persists it via ActivityLogRepository.insert(...).
  3. Fires-and-forgets the analytics dispatcher (§5) so the request isn't blocked on PostHog.
  4. Returns the inserted row.

Failures to write the row throw — an audit gap is an integrity problem. Failures to dispatch analytics log a warning but don't throw.

4. The Activity Type Taxonomy

The ActivityActionType enum defines every kind of action the platform records. The platform groups them into UI-level filter groups for the History tab:

Filter groupActivityActionType values
generationWORK_GENERATED, WORK_REGENERATED, WORK_GENERATION_FAILED
itemsITEM_ADDED, ITEM_UPDATED, ITEM_REMOVED
comparisonsCOMPARISON_ADDED, COMPARISON_REMOVED
taxonomyCATEGORY_CHANGE, TAG_CHANGE, COLLECTION_CHANGE
community_prCOMMUNITY_PR_MERGED
chatCHAT_CONVERSATION
authUSER_LOGGED_IN, USER_LOGGED_OUT, API_KEY_CREATED, API_KEY_REVOKED, OAUTH_LINKED, OAUTH_UNLINKED
accountACCOUNT_EXPORTED, ACCOUNT_IMPORTED, SYNC_PUSHED, SYNC_PULLED

The grouping lives on the API side (server-side mapping in the History controller) so the UI doesn't need to know the full enum.

5. The Async Analytics Dispatcher

ActivityLogAnalyticsDispatcher is an optional injection token (@Optional on the constructor). When wired, it's called after every successful write with the just-written row. Today's only implementation pushes events to PostHog with the row's actionType as the event name.

It runs out-of-band:

  • The dispatcher's dispatch(row) is called in a fire-and-forget way.
  • The dispatcher itself uses DistributedTaskLockService on a per-batch key to coalesce bursts.
  • A failed dispatch logs a warning but never bubbles up to the request.

This split ensures the platform's auditing path stays synchronous and reliable, while analytics push (a best-effort concern) doesn't add latency to user requests or risk losing audit rows.

6. The ActivityLog Row

@Entity('activity_log')
export class ActivityLog {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() userId: string;
@Column({ nullable: true }) workId: string | null;
@Column({ type: 'varchar' }) actionType: ActivityActionType;
@Column() action: string;
@Column({ type: 'varchar' }) status: ActivityStatus;
@Column() summary: string;
@Column({ type: 'jsonb', nullable: true }) details: Record<string, any> | null;
@Column({ type: 'jsonb', nullable: true }) metadata: Record<string, any> | null;
@Column({ nullable: true }) ipAddress: string | null;
@Column({ nullable: true }) userAgent: string | null;
@CreateDateColumn() createdAt: Date;
}

status is one of pending / in_progress / completed / failed. For long-running operations (generation runs), the platform records a single row with the final status — not a row per state transition.

7. Generation Counts Summary

activity-log-summary.ts exports two helpers used to format the human-readable summary line on history pages:

  • formatGenerationCountsSummary({addedCount, updatedCount, removedCount}) → "Generated 27 items, updated 5 items".
  • formatStoredActivitySummary(row) → uses the row's details to produce a similar string for non-generation activities.

These keep summary text consistent across the History tab, the notifications drawer, and the activity feed.

8. The Changelog Attachment Path

When a mutation produces a structured changelog (added/updated/removed items, taxonomy entries, comparisons), the writer:

  1. Builds the WorkChangelog payload.
  2. Writes it to the work_generation_history.changelog jsonb column inside the same transaction as the underlying mutation.
  3. Records a single activity_log row that points at the history row via details.historyId.

This dual-write happens in one transaction so we never have an activity-log entry without its corresponding history row, or vice versa.

9. PostHog Event Naming Convention

When the analytics dispatcher pushes to PostHog, the event name uses the snake-case actionType:

actionTypePostHog event name
WORK_GENERATEDwork_generated
ITEM_ADDEDitem_added
COMMUNITY_PR_MERGEDcommunity_pr_merged

Properties on the event mirror the row's details block. PII (email, IP) is not sent to PostHog — only userId (an opaque UUID) and the action context.

10. Performance & Scale

  • Write latency: < 5 ms typical. The synchronous write is a single insert into an indexed table.
  • Read latency: < 200 ms for a paginated history page on a work with 100K rows (offset-based pagination + index on (workId, createdAt DESC)).
  • Analytics dispatch never blocks the write path.
  • Storage growth: per-work partitioning is not used today — the platform uses a single activity_log table. At 10K active works the row count is manageable; at 1M+ this would call for partitioning, which is a documented future change.

11. Querying

ActivityLogQueryOptions supports:

FilterEffect
userIdRequired — every query is user-scoped
workIdRestrict to one work
actionTypeSingle value or array
actionGroupUI-level group (generation, items, etc.)
statusOne of the four statuses
since / untilTime range
limitDefaults to 20, capped at 100
offsetStandard offset pagination

The repository uses query-builder joins to load the related history row (changelog) when the caller asks for it via includeChangelog.

12. Idempotency & Deduplication

The activity log is not idempotent at the row level — duplicate inserts produce duplicate rows. Callers handle deduplication themselves where it matters:

  • The schedule dispatcher calls markRunFailed only once per run via the isAlreadyMarkedFailed guard (see Schedule Dispatcher).
  • Pipeline finalisation calls recordActivity exactly once per run.
  • Manual mutations (item add / taxonomy change) call once per HTTP request — natural idempotency from the request lifecycle.

13. Constitution Reconciliation

PrincipleHow activity-log respects it
I — Plugin-firstN/A.
II — Capability-drivenThe PostHog dispatcher is swappable via the ActivityLogAnalyticsDispatcher token.
III — Source-of-truth reposAudit data is platform-side, mirrors what's in user repos but doesn't replace it.
IV — Trigger.devHeavy fan-out (e.g. PostHog batch dispatch) runs as Trigger.dev tasks.
V — Forward-only migrationsactivity_log schema is additive; new action types add enum values.
VI — Testsactivity-log.service.spec.ts covers every action type + dispatcher fan-out.
VII — Secret hygieneRow details and metadata columns must never contain secret values.
VIII — Plugin countsN/A.
IX — Behaviour-firstThis spec describes observable audit behaviour.
X — Backwards-compatNew filter groups + activity types are additive.

14. References