EVENT_SCHEMAS — analytics-service
Sibling: APPLICATION_LOGIC · DOMAIN_MODEL · platform anchor: docs/04 Event-Driven Architecture, naming: docs/standards/NAMING §3
All subjects follow melmastoon.<service>.<aggregate>.<verb-past-tense>.v<n>. Service segment is analytics. Schemas are versioned per 04 §4 Versioning. JSON Schema files generated from TypeScript live in bin/event-schemas/.
1. Common envelope
Every event uses the platform EventEnvelope<T> from 04 §3:
interface EventEnvelope<T> {
id: string; // evt_<ulid>
subject: string; // melmastoon.analytics.…
occurredAt: string; // ISO-8601, UTC
tenantId: string; // tnt_…
correlationId: string; // cor_…
causationId?: string;
producedBy: { service: string; version: string; instance: string };
metadata: {
schemaVersion: number;
dataResidency: 'AF' | 'IN' | 'KSA' | 'EU';
orderingKey: string;
retentionClass: 'operational_2y' | 'operational_7y' | 'regulatory_10y_objectlock';
ai?: AIProvenance;
};
payload: T;
}
producedBy.service = "analytics-service". metadata.orderingKey defaults to tenantId for all subjects to preserve in-tenant ordering at the consumer side.
2. Subject taxonomy & retention
| Subject | Retention | Ordering key | Notes |
|---|---|---|---|
melmastoon.analytics.projection.refreshed.v1 | operational_2y | tenantId | per-projection event for any tenant impacted (we emit one envelope per refresh, with affected windows) |
melmastoon.analytics.projection.failed.v1 | operational_2y | tenantId | |
melmastoon.analytics.metric.computed.v1 | operational_2y | tenantId | high volume; downstream AI orchestrator subscribes selectively |
melmastoon.analytics.dashboard.created.v1 | operational_7y | tenantId | governance audit |
melmastoon.analytics.dashboard.updated.v1 | operational_7y | tenantId | |
melmastoon.analytics.dashboard.shared.v1 | operational_7y | tenantId | tracks Looker Studio token issuance |
melmastoon.analytics.query.executed.v1 | operational_2y | tenantId | sampled (1% by default) to control volume |
melmastoon.analytics.etl.started.v1 | operational_2y | etl_<jobId> | ordered per ETL job |
melmastoon.analytics.etl.completed.v1 | operational_2y | etl_<jobId> | |
melmastoon.analytics.etl.failed.v1 | operational_2y | etl_<jobId> | |
melmastoon.analytics.data_quality.alert.v1 | operational_7y | tenantId | drives notification + audit |
3. melmastoon.analytics.projection.refreshed.v1
interface ProjectionRefreshedV1 {
projectionId: string; // prj_…
projectionKey: string; // 'fact_reservation'
schemaVersion: number;
targetTable: string; // 'analytics_curated.fact_reservation_v1'
windowFrom: string; // ISO-8601
windowTo: string;
rowsAffected: number;
bytesScanned: number;
bytesWritten: number;
slotMs: number;
warehouseJobId: string;
freshnessLagMinutes: number;
trigger: 'cron' | 'on_demand' | 'backfill' | 'event';
etlRunId: string; // etr_…
}
JSON schema excerpt:
{
"$id": "https://schemas.melmastoon.ghasi.io/analytics/projection/refreshed.v1.json",
"type": "object",
"required": ["projectionId","projectionKey","schemaVersion","targetTable","windowFrom","windowTo","rowsAffected","bytesScanned","bytesWritten","slotMs","warehouseJobId","freshnessLagMinutes","trigger","etlRunId"],
"properties": {
"projectionId": { "type": "string", "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$" },
"trigger": { "enum": ["cron","on_demand","backfill","event"] }
}
}
Example envelope:
{
"id": "evt_01H…",
"subject": "melmastoon.analytics.projection.refreshed.v1",
"occurredAt": "2026-04-22T10:05:00.000Z",
"tenantId": "tnt_platform",
"correlationId": "cor_01H…",
"producedBy": { "service": "analytics-service", "version": "abc1234", "instance": "etl-worker-2" },
"metadata": { "schemaVersion": 1, "dataResidency": "AF", "orderingKey": "tnt_platform", "retentionClass": "operational_2y" },
"payload": {
"projectionId": "prj_01H…",
"projectionKey": "fact_reservation",
"schemaVersion": 1,
"targetTable": "analytics_curated.fact_reservation_v1",
"windowFrom": "2026-04-21T00:00:00Z",
"windowTo": "2026-04-22T10:00:00Z",
"rowsAffected": 1284,
"bytesScanned": 12849184,
"bytesWritten": 482918,
"slotMs": 412,
"warehouseJobId": "bq-job-…",
"freshnessLagMinutes": 3,
"trigger": "cron",
"etlRunId": "etr_01H…"
}
}
Note:
tenantIdistnt_platformbecause projections are platform-shared. Per-tenant impact is in row counts; subscribers that need tenant fan-out can read affected tenant ids by querying the curated table.
4. melmastoon.analytics.projection.failed.v1
interface ProjectionFailedV1 {
projectionId: string;
projectionKey: string;
windowFrom: string;
windowTo: string;
errorCode: string; // 'MELMASTOON.ANALYTICS.PROJECTION_SCHEMA_MISMATCH'
errorDetail: string;
retriable: boolean;
attempt: number;
etlRunId: string;
}
5. melmastoon.analytics.metric.computed.v1
interface MetricComputedV1 {
tenantId: string;
metricKey: string; // 'reservation.occupancy_pct'
metricVersion: number;
windowFrom: string;
windowTo: string;
filters: Record<string, unknown>;
dimensions: Array<{ key: string; value: string | number }>; // ordered
value: number;
unit: { kind: string; currency?: string; decimals: number };
provenance: {
bytesScanned: number;
slotMs: number;
warehouseJobId: string;
computedBy: 'etl' | 'on_demand' | 'ai_writeback';
};
}
Sampling: emitted every time for ETL-triggered computes; for on_demand we sample at 1 % to control volume (configurable).
Example payload:
{
"tenantId": "tnt_01H…",
"metricKey": "reservation.occupancy_pct",
"metricVersion": 1,
"windowFrom": "2026-04-21T00:00:00Z",
"windowTo": "2026-04-22T00:00:00Z",
"filters": { "property_id": "ppt_01H…" },
"dimensions": [{ "key": "date", "value": "2026-04-21" }],
"value": 71.34,
"unit": { "kind": "percent", "decimals": 2 },
"provenance": { "bytesScanned": 18324567, "slotMs": 1240, "warehouseJobId": "bq-job-…", "computedBy": "etl" }
}
6. melmastoon.analytics.dashboard.created.v1, .updated.v1, .shared.v1
interface DashboardCreatedV1 {
dashboardId: string;
ownerUserId: string;
scope: 'tenant' | 'property' | 'private';
propertyId?: string;
nameI18n: { default: string; values: Record<string, string> };
widgetIds: string[];
}
interface DashboardUpdatedV1 {
dashboardId: string;
changes: { field: string; before: unknown; after: unknown }[];
etag: number;
}
interface DashboardSharedV1 {
dashboardId: string;
shares: Array<
| { kind: 'user'; userId: string }
| { kind: 'role'; role: string }
| { kind: 'looker_studio_token'; tokenId: string; expiresAt: string }
>;
}
7. melmastoon.analytics.query.executed.v1
interface QueryExecutedV1 {
tenantId: string;
userId?: string; // omitted for service-to-service paths
scope: 'widget' | 'saved_query' | 'metric' | 'ad_hoc';
refId?: string; // wid_… | qry_… | met_…
bytesScanned: number;
bytesBilled: number;
slotMs: number;
cacheHit: boolean;
warehouseJobId: string;
durationMs: number;
}
Rate-limited by sampler at the publisher boundary.
8. melmastoon.analytics.etl.started.v1, .completed.v1, .failed.v1
interface EtlStartedV1 { etlJobId: string; etlRunId: string; projectionKey: string; windowFrom: string; windowTo: string; trigger: string; attempt: number; }
interface EtlCompletedV1 { etlJobId: string; etlRunId: string; projectionKey: string; rowsAffected: number; bytesScanned: number; bytesWritten: number; slotMs: number; durationMs: number; }
interface EtlFailedV1 { etlJobId: string; etlRunId: string; projectionKey: string; errorCode: string; errorDetail: string; retriable: boolean; attempt: number; }
Ordered per etl_<jobId> so consumers see the started → completed/failed sequence.
9. melmastoon.analytics.data_quality.alert.v1
interface DataQualityAlertV1 {
checkId: string;
checkKey: string; // 'fact_reservation.row_count_drift_24h'
table: string; // 'fact_reservation_v1'
rule: {
kind: 'row_count_drift' | 'freshness' | 'null_rate' | 'distinct_count' | 'business_rule' | 'schema_drift';
column?: string;
expression?: string;
windowMinutes?: number;
};
severity: 'warn' | 'critical';
observedValue: number;
expectedValue: number | null;
observedAt: string;
bigqueryJobId?: string;
affectedTenants?: string[]; // when the check is per-tenant
}
Routed to notification-service (tenant.admin), audit-service (Merkle anchor), and monitoring (Cloud Monitoring alert sink) consumers.
10. Consumed events
analytics-service consumes from two paths:
10.1 Pub/Sub-to-BigQuery managed sink (no application code)
All melmastoon.* topics with non-quarantined payloads are delivered directly into events_raw.<topic_unsuffixed> via managed Pub/Sub-to-BigQuery subscriptions. We own the subscription configuration as IaC; no service code runs in this path.
Per-subject mapping is declarative:
- subject_glob: melmastoon.*.*.*.v1
raw_table: events_raw.{{ replace(subject, '.', '_') }}
partition_field: ingestion_ts
cluster_fields: [tenant_id, data_residency]
retention_class_default: operational_2y
10.2 Application consumers (push subscriptions)
| Subject | Handler | Effect |
|---|---|---|
melmastoon.tenant.deleted.v1 | PurgeTenantUseCase | Drop view bindings, delete operational rows, anonymize regulated rows |
melmastoon.tenant.region_changed.v1 | RebindAuthorizedViewsUseCase | Update tenant residency + view binding |
melmastoon.ai.forecast.produced.v1 | IngestForecastWritebackUseCase | MERGE into fact_demand_forecast |
melmastoon.tenant.created.v1 | BootstrapTenantAnalyticsUseCase | Create authorized view binding, seed dq_check overrides |
melmastoon.iam.user.permission_revoked.v1 | InvalidateDashboardSharesUseCase | Drop dashboard shares for the user |
All handlers respect the inbox dedupe pattern ((subject, message_id) UNIQUE in Postgres analytics.inbox_processed).
11. Versioning policy
- Additive payload fields ship in
v1with optional flag. - Removing or renaming a field, changing semantics, or changing dimension shape requires a new
v2topic and a coexistence window of at least one quarter. - Each event payload carries
metadata.schemaVersionso consumers can pin precise sub-versions. - The
bin/check-event-schemas.tsCI gate compiles all schemas with AJV and forbids breaking changes within a major version.
Cross-references: 04 §4 Versioning, docs/standards/NAMING §3, APPLICATION_LOGIC §4.