Fact Emission Patterns
Structured event tracking using z0’s Fact primitive and FACT_TYPES constants.
Prerequisites: core-concepts.md
Updated for v0.10.0: All SDK components emit Facts for state changes using typed FACT_TYPES constants.
Overview
Section titled “Overview”The z0 SDK uses the Fact primitive not only for domain events but also for tracking its own internal behavior. This provides:
- Unified observability - Same pattern for domain and SDK events
- Type safety - Exported FACT_TYPES constants prevent typos
- Discoverability - IDE autocomplete shows all available fact types
- Audit trail - Every state change is recorded immutably
- Config versioning - Track which config version triggered which events
Fact Structure
Section titled “Fact Structure”All facts follow this structure:
interface Fact<T = unknown> { id: string; // Unique fact ID (ULID) type: string; // Event category (component name) subtype?: string; // Refined action/event timestamp: number; // When the event occurred (Unix ms) entity_id?: string; // Entity this fact relates to tenant_id?: string; // Tenant ownership correlation_id?: string; // For tracking related events data: T; // Event payload}Fact Pattern Convention
Section titled “Fact Pattern Convention”SDK facts use the pattern: {component}.{action}
Examples:
projection.migration_started- ProjectionEngine began migrationmeter.usage- MeterEngine tracked usagecircuit.state_changed- CircuitBreaker changed staterate_limit.triggered- RateLimiter denied requestthreshold.crossed- ThresholdMonitor detected crossing
This matches domain fact patterns and enables unified querying:
// Query all projection-related factsconst projectionFacts = await factManager.getFacts({ type: 'projection' });
// Query specific actionconst migrations = await factManager.getFacts({ type: 'projection', subtype: 'migration_started'});FACT_TYPES Constants
Section titled “FACT_TYPES Constants”All SDK components export typed constants for their fact patterns. This provides compile-time safety and IDE autocomplete.
Available Constants
Section titled “Available Constants”import { PROJECTION_FACT_TYPES, METER_FACT_TYPES, CB_FACT_TYPES, RL_FACT_TYPES, THRESHOLD_MONITOR_FACT_TYPES, SYSTEM_FACT_TYPES,} from '@z0-app/sdk';Why Use Constants?
Section titled “Why Use Constants?”Without constants (error-prone):
// Typo not caught until runtimeif (fact.type === 'projection' && fact.subtype === 'migraton_started') { // This never matches due to typo}With constants (compile-time safe):
import { PROJECTION_FACT_TYPES } from '@z0-app/sdk';
// Typo caught by TypeScriptif (fact.type + '.' + fact.subtype === PROJECTION_FACT_TYPES.MIGRATION_STARTED) { // Type-safe, autocomplete available}Pattern Matching
Section titled “Pattern Matching”Match facts using constants:
import { PROJECTION_FACT_TYPES } from '@z0-app/sdk';
const pattern = `${fact.type}.${fact.subtype}`;
switch (pattern) { case PROJECTION_FACT_TYPES.MIGRATION_STARTED: console.log('Migration started:', fact.data); break; case PROJECTION_FACT_TYPES.MIGRATION_COMPLETED: console.log('Migration completed in', fact.data.duration_ms, 'ms'); break; case PROJECTION_FACT_TYPES.MIGRATION_FAILED: console.error('Migration failed:', fact.data.error_message); break;}Component-Specific Fact Types
Section titled “Component-Specific Fact Types”ProjectionEngine
Section titled “ProjectionEngine”import { PROJECTION_FACT_TYPES } from '@z0-app/sdk';
PROJECTION_FACT_TYPES.MIGRATION_STARTED // 'projection.migration_started'PROJECTION_FACT_TYPES.MIGRATION_COMPLETED // 'projection.migration_completed'PROJECTION_FACT_TYPES.MIGRATION_FAILED // 'projection.migration_failed'When emitted:
- migration_started: Config storageVersion changes, migration begins
- migration_completed: Migration succeeded (includes duration, bucket count)
- migration_failed: Migration failed (original data intact, includes error)
Example:
import { ProjectionEngine, PROJECTION_FACT_TYPES } from '@z0-app/sdk';
const engine = new ProjectionEngine(config, { factManager, entityId: 'proj_daily_usage', tenantId: 'system',});
// Listen for migration eventsfactManager.on('fact', (fact) => { const pattern = `${fact.type}.${fact.subtype}`;
if (pattern === PROJECTION_FACT_TYPES.MIGRATION_STARTED) { console.log('Migrating from version', fact.data.from_version); console.log('to version', fact.data.to_version); }
if (pattern === PROJECTION_FACT_TYPES.MIGRATION_COMPLETED) { console.log('Migration completed in', fact.data.duration_ms, 'ms'); console.log('Migrated', fact.data.buckets_migrated, 'buckets'); }});MeterEngine
Section titled “MeterEngine”import { METER_FACT_TYPES } from '@z0-app/sdk';
METER_FACT_TYPES.USAGE // 'meter.usage'METER_FACT_TYPES.BUDGET_CHECK // 'meter.budget_check'When emitted:
- usage: Every time usage is incremented
- budget_check: When budget eligibility is checked (warning or denial)
Example:
import { MeterEngine, METER_FACT_TYPES } from '@z0-app/sdk';
const meter = new MeterEngine(config, sqlStorage, factManager);
// Increment usage - emits meter.usage factawait meter.incrementUsage('entity_123', 5);
// Check budget - emits meter.budget_check factconst allowed = await meter.checkBudget('entity_123', 10, 'hour');CircuitBreaker
Section titled “CircuitBreaker”import { CB_FACT_TYPES } from '@z0-app/sdk';
CB_FACT_TYPES.STATE_CHANGED // 'circuit.state_changed'CB_FACT_TYPES.CONFIG_UPDATED // 'circuit.config_updated'When emitted:
- state_changed: On any state transition (CLOSED↔OPEN↔HALF_OPEN)
- config_updated: When config changes via updateConfig()
Example:
import { CircuitBreaker, CB_FACT_TYPES } from '@z0-app/sdk';
const cb = new CircuitBreaker(config, factManager);
// Monitor state changesfactManager.on('fact', (fact) => { if (`${fact.type}.${fact.subtype}` === CB_FACT_TYPES.STATE_CHANGED) { if (fact.data.to_state === 'open') { // Circuit opened - send alert alerting.send({ severity: 'high', message: `Circuit ${fact.data.circuit_id} opened after ${fact.data.failures_at_transition} failures`, }); }
if (fact.data.from_state === 'half_open' && fact.data.to_state === 'closed') { // Circuit recovered alerting.send({ severity: 'info', message: `Circuit ${fact.data.circuit_id} recovered`, }); } }});RateLimiter
Section titled “RateLimiter”import { RL_FACT_TYPES } from '@z0-app/sdk';
RL_FACT_TYPES.TRIGGERED // 'rate_limit.triggered'RL_FACT_TYPES.RECOVERED // 'rate_limit.recovered'When emitted:
- triggered: Request denied due to rate limit
- recovered: Entity dropped back under rate limit
Example:
import { RateLimiter, RL_FACT_TYPES } from '@z0-app/sdk';
const limiter = new RateLimiter(config, factManager);
// Check rate limitconst allowed = await limiter.isAllowed('entity_123', 'api:/v1/users');
if (!allowed) { // Fact emitted: rate_limit.triggered // Includes: requests count, max_requests, window_ms}ThresholdMonitor
Section titled “ThresholdMonitor”import { THRESHOLD_MONITOR_FACT_TYPES } from '@z0-app/sdk';
THRESHOLD_MONITOR_FACT_TYPES.CROSSED // 'threshold.crossed'THRESHOLD_MONITOR_FACT_TYPES.RECOVERED // 'threshold.recovered'When emitted:
- crossed: Value dropped below threshold
- recovered: Value rose back above threshold
Example:
import { ThresholdMonitor, THRESHOLD_MONITOR_FACT_TYPES } from '@z0-app/sdk';
const monitor = new ThresholdMonitor({ factManager, monitorId: 'low_balance', tenantId: 'tnt_acme',});
const result = monitor.check(oldBalance, newBalance, threshold);
if (result === 'crossed') { // Fact emitted: threshold.crossed // Includes: threshold_value, old_value, new_value, monitor_id}Config Version Tracking
Section titled “Config Version Tracking”All facts include config_version field when component is constructed from Config<T>:
import type { Config } from '@z0-app/sdk';import { CircuitBreaker } from '@z0-app/sdk';
const config: Config<CircuitBreakerConfigSettings> = { id: 'cb_api', type: 'circuit_breaker', version: 3, // This version included in facts settings: { /* ... */ }, // ...};
const cb = new CircuitBreaker(config, factManager);
// Execute operation - if state changes, fact includes config_versionawait cb.execute(() => apiCall());
// Emitted fact:// {// type: 'circuit',// subtype: 'state_changed',// data: {// circuit_id: 'cb_api',// from_state: 'closed',// to_state: 'open',// config_version: 3, // <-- Tracked// failures_at_transition: 5// }// }Why track config_version?
- Debug behavioral changes: “Why did this behave differently last week?”
- Compliance: Regulatory requirement to track configuration
- A/B testing: Compare behavior across config versions
- Audit trail: Know exactly which config triggered which events
Querying Facts
Section titled “Querying Facts”By Component
Section titled “By Component”// All projection factsconst projectionFacts = await factManager.getFacts({ type: 'projection',});
// All meter factsconst meterFacts = await factManager.getFacts({ type: 'meter',});By Action
Section titled “By Action”import { PROJECTION_FACT_TYPES } from '@z0-app/sdk';
// All migration failuresconst failures = await factManager.getFacts({ type: 'projection', subtype: 'migration_failed',});
// Using pattern matchingconst allFacts = await factManager.getFacts();const migrationFacts = allFacts.filter(f => `${f.type}.${f.subtype}` === PROJECTION_FACT_TYPES.MIGRATION_STARTED);By Entity
Section titled “By Entity”// All facts for specific projectionconst projectionEvents = await factManager.getFacts({ entity_id: 'proj_daily_usage',});
// All facts for specific meterconst meterEvents = await factManager.getFacts({ entity_id: 'meter_api_calls',});Time Range
Section titled “Time Range”// Facts in last hourconst recentFacts = await factManager.getFacts({ from: Date.now() - 3600_000, to: Date.now(),});Building Dashboards
Section titled “Building Dashboards”Circuit Breaker Health
Section titled “Circuit Breaker Health”import { CB_FACT_TYPES } from '@z0-app/sdk';
async function getCircuitBreakerHealth() { const facts = await factManager.getFacts({ type: 'circuit', subtype: 'state_changed', from: Date.now() - 86400_000, // Last 24h });
const openCircuits = facts .filter(f => f.data.to_state === 'open') .map(f => ({ circuit_id: f.data.circuit_id, opened_at: f.timestamp, failures: f.data.failures_at_transition, }));
return { total_circuits: new Set(facts.map(f => f.data.circuit_id)).size, open_count: openCircuits.length, open_circuits: openCircuits, };}Projection Migration History
Section titled “Projection Migration History”import { PROJECTION_FACT_TYPES } from '@z0-app/sdk';
async function getMigrationHistory() { const migrations = await factManager.getFacts({ type: 'projection', });
const completed = migrations.filter(f => `${f.type}.${f.subtype}` === PROJECTION_FACT_TYPES.MIGRATION_COMPLETED );
return completed.map(f => ({ projection_id: f.data.projection_id, from_version: f.data.from_version, to_version: f.data.to_version, duration_ms: f.data.duration_ms, buckets_migrated: f.data.buckets_migrated, timestamp: f.timestamp, }));}Rate Limit Violations
Section titled “Rate Limit Violations”import { RL_FACT_TYPES } from '@z0-app/sdk';
async function getRateLimitViolations() { const facts = await factManager.getFacts({ type: 'rate_limit', subtype: 'triggered', from: Date.now() - 3600_000, // Last hour });
// Group by entity const byEntity = facts.reduce((acc, f) => { const entityId = f.entity_id || 'unknown'; if (!acc[entityId]) acc[entityId] = []; acc[entityId].push(f); return acc; }, {} as Record<string, typeof facts>);
return Object.entries(byEntity) .map(([entity_id, violations]) => ({ entity_id, violation_count: violations.length, latest: violations[violations.length - 1], })) .sort((a, b) => b.violation_count - a.violation_count);}Best Practices
Section titled “Best Practices”1. Always Use FACT_TYPES Constants
Section titled “1. Always Use FACT_TYPES Constants”// ❌ BAD - String literals (typo-prone)if (fact.type === 'projection' && fact.subtype === 'migraton_started') { // Typo not caught}
// ✅ GOOD - Type-safe constantsimport { PROJECTION_FACT_TYPES } from '@z0-app/sdk';const pattern = `${fact.type}.${fact.subtype}`;if (pattern === PROJECTION_FACT_TYPES.MIGRATION_STARTED) { // Compile-time safety}2. Include Context in Fact Data
Section titled “2. Include Context in Fact Data”All fact data should include:
- Component identifier (meter_id, circuit_id, projection_id)
- Config version (when using Config
) - Relevant values (old/new state, counts, thresholds)
- Tenant context (tenant_id)
// Example fact data structure{ type: 'circuit', subtype: 'state_changed', entity_id: 'cb_api_gateway', tenant_id: 'system', data: { circuit_id: 'cb_api_gateway', // Component ID from_state: 'closed', // Old state to_state: 'open', // New state failures_at_transition: 5, // Context config_version: 2, // Config tracking }}3. Wire FactManager for Production
Section titled “3. Wire FactManager for Production”Enable fact emission in production by passing factManager to components:
// Development (no facts)const cb = new CircuitBreaker(config);
// Production (with facts)const cb = new CircuitBreaker(config, factManager);4. Query with Filters
Section titled “4. Query with Filters”Always filter facts to reduce data transfer:
// ❌ BAD - Fetch all factsconst allFacts = await factManager.getFacts();const filtered = allFacts.filter(/* ... */);
// ✅ GOOD - Filter at sourceconst filtered = await factManager.getFacts({ type: 'projection', entity_id: 'proj_daily_usage', from: Date.now() - 86400_000,});5. Monitor Critical Patterns
Section titled “5. Monitor Critical Patterns”Set up alerts for critical fact patterns:
import { CB_FACT_TYPES, PROJECTION_FACT_TYPES } from '@z0-app/sdk';
const criticalPatterns = [ CB_FACT_TYPES.STATE_CHANGED, PROJECTION_FACT_TYPES.MIGRATION_FAILED,];
factManager.on('fact', (fact) => { const pattern = `${fact.type}.${fact.subtype}`;
if (criticalPatterns.includes(pattern)) { alerting.send({ severity: 'high', pattern, data: fact.data, }); }});Summary
Section titled “Summary”| Concept | Description |
|---|---|
| Fact Pattern | {component}.{action} (e.g., ‘meter.usage’) |
| FACT_TYPES | Exported constants for type-safe matching |
| Config Version | Tracked in fact data when using Config |
| Querying | Filter by type, subtype, entity_id, time range |
| Observability | Unified pattern for domain and SDK events |
Available FACT_TYPES:
PROJECTION_FACT_TYPES- ProjectionEngine eventsMETER_FACT_TYPES- MeterEngine usage trackingCB_FACT_TYPES- CircuitBreaker state changesRL_FACT_TYPES- RateLimiter violationsTHRESHOLD_MONITOR_FACT_TYPES- Threshold crossingsSYSTEM_FACT_TYPES- System-level events
By using typed constants and consistent patterns, fact emission provides type-safe, discoverable observability across all SDK components.