e11y 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +130 -10
  3. data/CHANGELOG.md +80 -1
  4. data/CLAUDE.md +168 -0
  5. data/CONTRIBUTING.md +640 -0
  6. data/README.md +165 -701
  7. data/RELEASE.md +41 -12
  8. data/Rakefile +249 -57
  9. data/config/README.md +1 -1
  10. data/config/loki-local-config.yaml +12 -0
  11. data/config/otel-collector-config.yaml +44 -0
  12. data/cucumber.yml +1 -0
  13. data/docker-compose.yml +18 -2
  14. data/docs/ADAPTERS.md +76 -0
  15. data/docs/ADAPTIVE_SAMPLING.md +59 -0
  16. data/docs/COMPARISON.md +104 -0
  17. data/docs/CONFIGURATION.md +52 -0
  18. data/docs/DISTRIBUTED_TRACING.md +44 -0
  19. data/docs/LIMITATIONS.md +13 -0
  20. data/docs/METRICS_DSL.md +84 -0
  21. data/docs/PERFORMANCE.md +60 -0
  22. data/docs/PII_FILTERING.md +40 -0
  23. data/docs/PRESETS.md +65 -0
  24. data/docs/QUICK-START.md +546 -587
  25. data/docs/RAILS_INTEGRATION.md +79 -0
  26. data/docs/SCHEMA_VALIDATION.md +63 -0
  27. data/docs/SLO-PROMQL-ALERTS.md +161 -0
  28. data/docs/TESTING.md +69 -0
  29. data/docs/{ADR-001-architecture.md → architecture/ADR-001-architecture.md} +36 -65
  30. data/docs/{ADR-002-metrics-yabeda.md → architecture/ADR-002-metrics-yabeda.md} +62 -236
  31. data/docs/architecture/ADR-003-slo-observability.md +1402 -0
  32. data/docs/{ADR-004-adapter-architecture.md → architecture/ADR-004-adapter-architecture.md} +163 -146
  33. data/docs/{ADR-005-tracing-context.md → architecture/ADR-005-tracing-context.md} +10 -9
  34. data/docs/{ADR-006-security-compliance.md → architecture/ADR-006-security-compliance.md} +184 -191
  35. data/docs/{ADR-007-opentelemetry-integration.md → architecture/ADR-007-opentelemetry-integration.md} +3 -21
  36. data/docs/{ADR-008-rails-integration.md → architecture/ADR-008-rails-integration.md} +182 -743
  37. data/docs/{ADR-009-cost-optimization.md → architecture/ADR-009-cost-optimization.md} +45 -54
  38. data/docs/architecture/ADR-010-developer-experience.md +522 -0
  39. data/docs/{ADR-011-testing-strategy.md → architecture/ADR-011-testing-strategy.md} +44 -86
  40. data/docs/{ADR-012-event-evolution.md → architecture/ADR-012-event-evolution.md} +11 -11
  41. data/docs/{ADR-013-reliability-error-handling.md → architecture/ADR-013-reliability-error-handling.md} +37 -12
  42. data/docs/{ADR-014-event-driven-slo.md → architecture/ADR-014-event-driven-slo.md} +12 -24
  43. data/docs/{ADR-015-middleware-order.md → architecture/ADR-015-middleware-order.md} +43 -59
  44. data/docs/{ADR-016-self-monitoring-slo.md → architecture/ADR-016-self-monitoring-slo.md} +58 -355
  45. data/docs/{ADR-017-multi-rails-compatibility.md → architecture/ADR-017-multi-rails-compatibility.md} +4 -11
  46. data/docs/architecture/ADR-018-memory-optimization.md +366 -0
  47. data/docs/{ADR-INDEX.md → architecture/ADR-INDEX.md} +11 -6
  48. data/docs/plans/2026-03-20-browser-overlay-svelte.md +281 -0
  49. data/docs/{00-ICP-AND-TIMELINE.md → prd/00-ICP-AND-TIMELINE.md} +6 -6
  50. data/docs/{01-SCALE-REQUIREMENTS.md → prd/01-SCALE-REQUIREMENTS.md} +6 -6
  51. data/docs/prd/01-overview-vision.md +19 -14
  52. data/docs/use_cases/README.md +22 -23
  53. data/docs/use_cases/UC-001-request-scoped-debug-buffering.md +50 -44
  54. data/docs/use_cases/UC-002-business-event-tracking.md +26 -95
  55. data/docs/use_cases/UC-003-event-metrics.md +66 -0
  56. data/docs/use_cases/UC-004-zero-config-slo-tracking.md +33 -684
  57. data/docs/use_cases/UC-005-sentry-integration.md +13 -15
  58. data/docs/use_cases/UC-006-trace-context-management.md +30 -28
  59. data/docs/use_cases/UC-007-pii-filtering.md +35 -87
  60. data/docs/use_cases/UC-008-opentelemetry-integration.md +51 -89
  61. data/docs/use_cases/UC-009-multi-service-tracing.md +30 -178
  62. data/docs/use_cases/UC-010-background-job-tracking.md +24 -91
  63. data/docs/use_cases/UC-011-rate-limiting.md +95 -168
  64. data/docs/use_cases/UC-012-audit-trail.md +21 -46
  65. data/docs/use_cases/UC-013-high-cardinality-protection.md +29 -167
  66. data/docs/use_cases/UC-014-adaptive-sampling.md +2 -2
  67. data/docs/use_cases/UC-015-cost-optimization.md +46 -99
  68. data/docs/use_cases/UC-016-rails-logger-migration.md +39 -213
  69. data/docs/use_cases/UC-017-local-development.md +203 -777
  70. data/docs/use_cases/UC-018-testing-events.md +3 -3
  71. data/docs/use_cases/UC-019-retention-based-routing.md +53 -106
  72. data/docs/use_cases/UC-020-event-versioning.md +8 -9
  73. data/docs/use_cases/UC-021-error-handling-retry-dlq.md +18 -22
  74. data/docs/use_cases/UC-022-event-registry.md +15 -21
  75. data/docs/use_cases/backlog.md +119 -87
  76. data/e11y.gemspec +2 -2
  77. data/gems/e11y-devtools/README.md +158 -0
  78. data/gems/e11y-devtools/config/routes.rb +15 -0
  79. data/gems/e11y-devtools/e11y-devtools.gemspec +25 -0
  80. data/gems/e11y-devtools/exe/e11y +34 -0
  81. data/gems/e11y-devtools/frontend/.gitignore +24 -0
  82. data/gems/e11y-devtools/frontend/README.md +51 -0
  83. data/gems/e11y-devtools/frontend/index.html +14 -0
  84. data/gems/e11y-devtools/frontend/package-lock.json +3707 -0
  85. data/gems/e11y-devtools/frontend/package.json +28 -0
  86. data/gems/e11y-devtools/frontend/public/mocks/v1/events/recent.json +4205 -0
  87. data/gems/e11y-devtools/frontend/public/mocks/v1/interactions.json +194 -0
  88. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/0a2e04027cfa22d014bc22e8b27cd913/events.json +86 -0
  89. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/0e1543af6a630fb3af6b52283154b3e0/events.json +169 -0
  90. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/1838b691faa49564f97db8592ff3978d/events.json +78 -0
  91. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/29f198f6588dacffb687777eb5f8f118/events.json +197 -0
  92. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/34bc3c9c0097de28a7a6f99b90a8e7bc/events.json +194 -0
  93. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/3ba6c20d068ab9cee00e51b180e66444/events.json +184 -0
  94. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/435bfd8f17b9009146a79812d7c3726d/events.json +144 -0
  95. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/4c7676e3fe668e99edb2b94d7d5678a9/events.json +222 -0
  96. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/6daf0d47974bedfc55d5de7004a3ea9f/events.json +194 -0
  97. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8a81ada42834d15f287bb40010043605/events.json +194 -0
  98. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8c0a98900edaae105469df8daedccf02/events.json +198 -0
  99. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8e4f645180f8a7d1dce426b07380466b/events.json +222 -0
  100. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/93db346fa5d44a032605a13b627f4b80/events.json +128 -0
  101. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/98ff6146faf7bd9be8bd03a8275817ba/events.json +223 -0
  102. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/9997ddd0247bc7e25f2ca7a5c415c93d/events.json +197 -0
  103. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/99e35f8ef3baedd798cc4fd085980ad9/events.json +194 -0
  104. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/b4f3095c1909924cbc98889a86c83d6d/events.json +131 -0
  105. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/b54b7fc32b7575a7110de809d11ccda0/events.json +128 -0
  106. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/c0b48033fa06746bcc5886745e053cff/events.json +169 -0
  107. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/c44649ac76701b4558927cd2305ab535/events.json +169 -0
  108. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/d601ae3320057580a39dbdac2edfdf4a/events.json +248 -0
  109. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/e67e724bab422d2b52eeb49635e512e1/events.json +194 -0
  110. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/e6c72765a28f158a8485b35fa63f73da/events.json +194 -0
  111. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/f541b87405c9a54819b18ebe529f6419/events.json +194 -0
  112. data/gems/e11y-devtools/frontend/scripts/generate_mocks.rb +397 -0
  113. data/gems/e11y-devtools/frontend/src/App.svelte +827 -0
  114. data/gems/e11y-devtools/frontend/src/components/Fab.svelte +19 -0
  115. data/gems/e11y-devtools/frontend/src/components/FilterBar.svelte +38 -0
  116. data/gems/e11y-devtools/frontend/src/components/FullscreenPanel.svelte +82 -0
  117. data/gems/e11y-devtools/frontend/src/components/InteractionsTimeline.svelte +264 -0
  118. data/gems/e11y-devtools/frontend/src/components/RecentHistogram.svelte +354 -0
  119. data/gems/e11y-devtools/frontend/src/lib/api.ts +37 -0
  120. data/gems/e11y-devtools/frontend/src/lib/eventIdentity.ts +12 -0
  121. data/gems/e11y-devtools/frontend/src/lib/format.ts +37 -0
  122. data/gems/e11y-devtools/frontend/src/lib/listFilter.ts +43 -0
  123. data/gems/e11y-devtools/frontend/src/lib/recentVolume.ts +80 -0
  124. data/gems/e11y-devtools/frontend/src/lib/router.ts +12 -0
  125. data/gems/e11y-devtools/frontend/src/lib/transitions.ts +34 -0
  126. data/gems/e11y-devtools/frontend/src/lib/viewportOrigin.ts +25 -0
  127. data/gems/e11y-devtools/frontend/src/main.ts +8 -0
  128. data/gems/e11y-devtools/frontend/src/overlay-entry.ts +24 -0
  129. data/gems/e11y-devtools/frontend/src/overlay.css +1080 -0
  130. data/gems/e11y-devtools/frontend/svelte.config.js +2 -0
  131. data/gems/e11y-devtools/frontend/test_puppeteer.js +41 -0
  132. data/gems/e11y-devtools/frontend/test_scale.js +3 -0
  133. data/gems/e11y-devtools/frontend/tsconfig.app.json +21 -0
  134. data/gems/e11y-devtools/frontend/tsconfig.json +7 -0
  135. data/gems/e11y-devtools/frontend/tsconfig.node.json +26 -0
  136. data/gems/e11y-devtools/frontend/vite.config.ts +36 -0
  137. data/gems/e11y-devtools/lib/e11y/devtools/mcp/server.rb +96 -0
  138. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tool_base.rb +25 -0
  139. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/clear.rb +31 -0
  140. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/errors.rb +35 -0
  141. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/event_detail.rb +33 -0
  142. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/events_by_trace.rb +33 -0
  143. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/interactions.rb +40 -0
  144. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/recent_events.rb +34 -0
  145. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/search.rb +34 -0
  146. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/stats.rb +30 -0
  147. data/gems/e11y-devtools/lib/e11y/devtools/overlay/assets/overlay.js +20 -0
  148. data/gems/e11y-devtools/lib/e11y/devtools/overlay/controller.rb +94 -0
  149. data/gems/e11y-devtools/lib/e11y/devtools/overlay/engine.rb +26 -0
  150. data/gems/e11y-devtools/lib/e11y/devtools/overlay/middleware.rb +80 -0
  151. data/gems/e11y-devtools/lib/e11y/devtools/overlay/rails_controller.rb +67 -0
  152. data/gems/e11y-devtools/lib/e11y/devtools/tui/app.rb +262 -0
  153. data/gems/e11y-devtools/lib/e11y/devtools/tui/grouping.rb +66 -0
  154. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_detail.rb +62 -0
  155. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_list.rb +70 -0
  156. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/interaction_list.rb +47 -0
  157. data/gems/e11y-devtools/lib/e11y/devtools/version.rb +8 -0
  158. data/gems/e11y-devtools/lib/e11y/devtools.rb +13 -0
  159. data/gems/e11y-devtools/spec/e11y/devtools/mcp/tools_spec.rb +107 -0
  160. data/gems/e11y-devtools/spec/e11y/devtools/overlay/controller_spec.rb +91 -0
  161. data/gems/e11y-devtools/spec/e11y/devtools/overlay/middleware_spec.rb +46 -0
  162. data/gems/e11y-devtools/spec/e11y/devtools/tui/app_spec.rb +85 -0
  163. data/gems/e11y-devtools/spec/e11y/devtools/tui/grouping_spec.rb +64 -0
  164. data/gems/e11y-devtools/spec/spec_helper.rb +5 -0
  165. data/gems/e11y-devtools/spec/tui/widgets/event_list_spec.rb +44 -0
  166. data/gems/e11y-devtools/spec/tui/widgets/interaction_list_spec.rb +62 -0
  167. data/lib/e11y/adapters/audit_encrypted.rb +53 -11
  168. data/lib/e11y/adapters/base.rb +33 -34
  169. data/lib/e11y/adapters/dev_log/file_store.rb +143 -0
  170. data/lib/e11y/adapters/dev_log/query.rb +219 -0
  171. data/lib/e11y/adapters/dev_log.rb +118 -0
  172. data/lib/e11y/adapters/file.rb +3 -6
  173. data/lib/e11y/adapters/in_memory.rb +52 -5
  174. data/lib/e11y/adapters/in_memory_test.rb +29 -0
  175. data/lib/e11y/adapters/loki.rb +58 -23
  176. data/lib/e11y/adapters/null.rb +82 -0
  177. data/lib/e11y/adapters/opentelemetry_collector.rb +183 -0
  178. data/lib/e11y/adapters/otel_logs.rb +136 -23
  179. data/lib/e11y/adapters/sentry.rb +4 -7
  180. data/lib/e11y/adapters/stdout.rb +73 -7
  181. data/lib/e11y/adapters/yabeda.rb +153 -29
  182. data/lib/e11y/buffers/adaptive_buffer.rb +3 -17
  183. data/lib/e11y/buffers/{request_scoped_buffer.rb → ephemeral_buffer.rb} +72 -58
  184. data/lib/e11y/buffers/ring_buffer.rb +3 -16
  185. data/lib/e11y/configuration.rb +272 -0
  186. data/lib/e11y/console.rb +10 -17
  187. data/lib/e11y/current.rb +53 -1
  188. data/lib/e11y/debug/pipeline_inspector.rb +96 -0
  189. data/lib/e11y/documentation/generator.rb +48 -0
  190. data/lib/e11y/event/base.rb +176 -82
  191. data/lib/e11y/event/value_sampling_config.rb +1 -5
  192. data/lib/e11y/events/rails/database/query.rb +1 -4
  193. data/lib/e11y/events/rails/job/failed.rb +2 -0
  194. data/lib/e11y/instruments/active_job.rb +44 -12
  195. data/lib/e11y/instruments/rails_instrumentation.rb +49 -24
  196. data/lib/e11y/instruments/sidekiq.rb +135 -31
  197. data/lib/e11y/linters/base.rb +11 -0
  198. data/lib/e11y/linters/pii/pii_declaration_linter.rb +120 -0
  199. data/lib/e11y/linters/slo/config_consistency_linter.rb +76 -0
  200. data/lib/e11y/linters/slo/explicit_declaration_linter.rb +36 -0
  201. data/lib/e11y/linters/slo/slo_status_from_linter.rb +41 -0
  202. data/lib/e11y/logger/bridge.rb +26 -7
  203. data/lib/e11y/metrics/cardinality_protection.rb +10 -15
  204. data/lib/e11y/metrics/cardinality_tracker.rb +16 -6
  205. data/lib/e11y/metrics/registry.rb +3 -5
  206. data/lib/e11y/metrics/test_backend.rb +62 -0
  207. data/lib/e11y/metrics.rb +56 -10
  208. data/lib/e11y/middleware/adapter_resolver.rb +40 -0
  209. data/lib/e11y/middleware/audit_signing.rb +43 -6
  210. data/lib/e11y/middleware/baggage_protection.rb +75 -0
  211. data/lib/e11y/middleware/dev_log_source.rb +24 -0
  212. data/lib/e11y/middleware/event_slo.rb +23 -9
  213. data/lib/e11y/middleware/otel_span.rb +23 -0
  214. data/lib/e11y/middleware/pii_filter.rb +104 -75
  215. data/lib/e11y/middleware/rate_limiting.rb +54 -27
  216. data/lib/e11y/middleware/request.rb +70 -23
  217. data/lib/e11y/middleware/routing.rb +78 -21
  218. data/lib/e11y/middleware/sampling.rb +66 -17
  219. data/lib/e11y/middleware/self_monitoring_emit.rb +39 -0
  220. data/lib/e11y/middleware/trace_context.rb +45 -10
  221. data/lib/e11y/middleware/track_latency.rb +34 -0
  222. data/lib/e11y/middleware/validation.rb +7 -16
  223. data/lib/e11y/middleware/versioning.rb +26 -22
  224. data/lib/e11y/opentelemetry/semantic_conventions.rb +109 -0
  225. data/lib/e11y/opentelemetry/span_creator.rb +142 -0
  226. data/lib/e11y/pii/patterns.rb +12 -1
  227. data/lib/e11y/pipeline/builder.rb +4 -4
  228. data/lib/e11y/presets/audit_event.rb +13 -2
  229. data/lib/e11y/railtie.rb +52 -14
  230. data/lib/e11y/registry.rb +306 -0
  231. data/lib/e11y/reliability/circuit_breaker.rb +19 -21
  232. data/lib/e11y/reliability/dlq/base.rb +71 -0
  233. data/lib/e11y/reliability/dlq/file_adapter.rb +301 -0
  234. data/lib/e11y/reliability/dlq/file_storage.rb +63 -34
  235. data/lib/e11y/reliability/dlq/filter.rb +37 -54
  236. data/lib/e11y/reliability/retry_handler.rb +26 -29
  237. data/lib/e11y/reliability/retry_rate_limiter.rb +3 -11
  238. data/lib/e11y/sampling/error_spike_detector.rb +0 -2
  239. data/lib/e11y/sampling/load_monitor.rb +5 -9
  240. data/lib/e11y/sampling/stratified_tracker.rb +18 -0
  241. data/lib/e11y/self_monitoring/buffer_monitor.rb +2 -0
  242. data/lib/e11y/self_monitoring/performance_monitor.rb +19 -61
  243. data/lib/e11y/self_monitoring/reliability_monitor.rb +4 -74
  244. data/lib/e11y/slo/config_loader.rb +40 -0
  245. data/lib/e11y/slo/config_validator.rb +58 -0
  246. data/lib/e11y/slo/dashboard_generator.rb +122 -0
  247. data/lib/e11y/slo/event_driven.rb +8 -0
  248. data/lib/e11y/slo/tracker.rb +31 -4
  249. data/lib/e11y/testing/have_tracked_event_matcher.rb +190 -0
  250. data/lib/e11y/testing/rspec_matchers.rb +21 -0
  251. data/lib/e11y/testing/snapshot_matcher.rb +86 -0
  252. data/lib/e11y/trace_context/sampler.rb +35 -0
  253. data/lib/e11y/tracing/faraday_middleware.rb +31 -0
  254. data/lib/e11y/tracing/net_http_patch.rb +33 -0
  255. data/lib/e11y/tracing/propagator.rb +144 -0
  256. data/lib/e11y/tracing.rb +47 -0
  257. data/lib/e11y/version.rb +1 -1
  258. data/lib/e11y/versioning/version_extractor.rb +32 -0
  259. data/lib/e11y.rb +123 -266
  260. data/lib/generators/e11y/event/event_generator.rb +22 -0
  261. data/lib/generators/e11y/event/templates/event.rb.tt +16 -0
  262. data/lib/generators/e11y/grafana_dashboard/grafana_dashboard_generator.rb +30 -0
  263. data/lib/generators/e11y/grafana_dashboard/templates/e11y_dashboard.json +81 -0
  264. data/lib/generators/e11y/install/install_generator.rb +34 -0
  265. data/lib/generators/e11y/install/templates/e11y.rb +239 -0
  266. data/lib/generators/e11y/prometheus_alerts/prometheus_alerts_generator.rb +29 -0
  267. data/lib/generators/e11y/prometheus_alerts/templates/e11y_alerts.yml +28 -0
  268. data/lib/tasks/e11y_docs.rake +30 -0
  269. data/lib/tasks/e11y_events.rake +71 -0
  270. data/lib/tasks/e11y_lint.rake +91 -0
  271. data/lib/tasks/e11y_slo.rake +29 -0
  272. metadata +186 -39
  273. data/docs/ADR-003-slo-observability.md +0 -3337
  274. data/docs/ADR-010-developer-experience.md +0 -2166
  275. data/docs/API-REFERENCE-L28.md +0 -914
  276. data/docs/COMPREHENSIVE-CONFIGURATION.md +0 -2366
  277. data/docs/CONTRIBUTING.md +0 -312
  278. data/docs/IMPLEMENTATION_NOTES.md +0 -2804
  279. data/docs/IMPLEMENTATION_PLAN.md +0 -1971
  280. data/docs/IMPLEMENTATION_PLAN_ARCHITECTURE.md +0 -586
  281. data/docs/PLAN.md +0 -148
  282. data/docs/README.md +0 -296
  283. data/docs/design/00-memory-optimization.md +0 -593
  284. data/docs/guides/MIGRATION-L27-L28.md +0 -692
  285. data/docs/guides/PERFORMANCE-BENCHMARKS.md +0 -434
  286. data/docs/guides/README.md +0 -44
  287. data/docs/use_cases/UC-003-pattern-based-metrics.md +0 -1627
  288. data/lib/e11y/adapters/registry.rb +0 -141
@@ -0,0 +1,827 @@
1
+ <script lang="ts">
2
+ import Fab from "./components/Fab.svelte"
3
+ import FilterBar from "./components/FilterBar.svelte"
4
+ import FullscreenPanel from "./components/FullscreenPanel.svelte"
5
+ import InteractionsTimeline, { type TimelineTimeRange } from "./components/InteractionsTimeline.svelte"
6
+ import RecentHistogram from "./components/RecentHistogram.svelte"
7
+ import { fetchInteractions, fetchRecent, fetchTraceEvents } from "./lib/api"
8
+ import { eventKey } from "./lib/eventIdentity"
9
+ import { formatInteractionStarted, summarizeTraceIds } from "./lib/format"
10
+ import { filterEventList, type ListSeverityFilter } from "./lib/listFilter"
11
+ import { buildRecentVolumeBuckets, eventTimestampMs, type HistogramTimeRange } from "./lib/recentVolume"
12
+ import type { OverlayRoute, SourceFilter } from "./lib/router"
13
+ import type { CircleOrigin } from "./lib/transitions"
14
+ import { originFallbackFabCorner, originFromFabButton } from "./lib/viewportOrigin"
15
+
16
+ const SPLIT_MIN_PX = 900
17
+
18
+ let panelOpen = $state(false)
19
+ let panelCircleOrigin = $state<CircleOrigin | null>(null)
20
+ let source = $state<SourceFilter>("web")
21
+ let route = $state<OverlayRoute>({ screen: "interactions" })
22
+ let interactions = $state<Record<string, unknown>[]>([])
23
+ let events = $state<Record<string, unknown>[]>([])
24
+ let recentEvents = $state<Record<string, unknown>[]>([])
25
+ let loadError = $state<string | null>(null)
26
+ /** Selected interaction row key when using wide split layout. */
27
+ let splitSelectedKey = $state<string | null>(null)
28
+ let layoutWide = $state(false)
29
+
30
+ /** Global text search and severity filter across all lists. */
31
+ let globalSearch = $state("")
32
+ let globalSeverity = $state<ListSeverityFilter>("all")
33
+
34
+ let rowExpanded = $state<Record<string, boolean>>({})
35
+ /** Index in the current filtered trace list; neighbors ±2 highlighted after returning from detail. */
36
+ let contextAnchorIndex = $state<number | null>(null)
37
+
38
+ /** Problems-tab histogram: filter errors to [startMs, endMs] (UTC, inclusive). */
39
+ let histogramTimeRange = $state<HistogramTimeRange | null>(null)
40
+
41
+ /** Interactions-tab timeline: filter interactions to [startMs, endMs] (UTC, inclusive). */
42
+ let interactionsTimeRange = $state<TimelineTimeRange | null>(null)
43
+
44
+ let prevRecentKeys = $state<Set<string>>(new Set())
45
+ let firstRecentPoll = $state(true)
46
+ let pulseKind = $state<"none" | "error" | "warn">("none")
47
+ let pulseTimer: ReturnType<typeof setTimeout> | null = null
48
+
49
+ const POLL_MS = 2000
50
+ const PULSE_MS = 3000
51
+
52
+ let problemEvents = $derived.by(() =>
53
+ recentEvents.filter((e) => e.severity === "error" || e.severity === "fatal")
54
+ )
55
+
56
+ let filteredInteractions = $derived.by(() => {
57
+ let rows = interactions
58
+
59
+ if (interactionsTimeRange) {
60
+ const { startMs, endMs } = interactionsTimeRange
61
+ rows = rows.filter((i) => {
62
+ const t = new Date(String(i.started_at || "")).getTime()
63
+ if (isNaN(t)) return false
64
+ return t >= startMs && t <= endMs
65
+ })
66
+ }
67
+
68
+ if (globalSeverity !== "all") {
69
+ rows = rows.filter((i) => {
70
+ const s = Number(i.status) || 200
71
+ const hasErr = !!i.has_error
72
+ if (globalSeverity === "error") return s >= 500 || hasErr
73
+ if (globalSeverity === "warn") return s >= 400 && s < 500
74
+ if (globalSeverity === "rest") return s < 400 && !hasErr
75
+ return true
76
+ })
77
+ }
78
+
79
+ const q = globalSearch.trim().toLowerCase()
80
+ if (q) {
81
+ rows = rows.filter((i) => {
82
+ const path = String(i.path || "").toLowerCase()
83
+ const method = String(i.method || "").toLowerCase()
84
+ const traceIds = ((i.trace_ids as string[]) || []).join(" ").toLowerCase()
85
+ return path.includes(q) || method.includes(q) || traceIds.includes(q)
86
+ })
87
+ }
88
+
89
+ return rows
90
+ })
91
+
92
+ let filteredProblemEvents = $derived.by(() => {
93
+ let rows = filterEventList(problemEvents, globalSeverity, globalSearch)
94
+ const r = histogramTimeRange
95
+ if (r) {
96
+ const buckets = buildRecentVolumeBuckets(recentEvents, 32)
97
+ const n = buckets.length
98
+ if (n === 0) {
99
+ rows = []
100
+ } else {
101
+ const tMin = buckets[0].t0
102
+ const tMax = buckets[n - 1].t1
103
+ const w = (tMax - tMin) / n
104
+
105
+ // If lo/hi are missing (e.g. old state), fallback to time-based filter
106
+ if (r.lo == null || r.hi == null) {
107
+ rows = rows.filter((ev) => {
108
+ const t = eventTimestampMs(ev)
109
+ if (t == null) return false
110
+ return t >= r.startMs && t <= r.endMs
111
+ })
112
+ } else {
113
+ const lo = Math.max(0, Math.min(r.lo, n - 1))
114
+ const hi = Math.max(0, Math.min(r.hi, n - 1))
115
+ const i0 = Math.min(lo, hi)
116
+ const i1 = Math.max(lo, hi)
117
+ rows = rows.filter((ev) => {
118
+ const t = eventTimestampMs(ev)
119
+ if (t == null) return false
120
+ const idx = Math.min(n - 1, Math.max(0, Math.floor((t - tMin) / w)))
121
+ return idx >= i0 && idx <= i1
122
+ })
123
+ }
124
+ }
125
+ }
126
+ return rows
127
+ })
128
+
129
+ let filteredTraceEvents = $derived.by(() =>
130
+ filterEventList(events, globalSeverity, globalSearch)
131
+ )
132
+
133
+ let activeTraceId = $derived.by(() => {
134
+ if (route.screen === "events") return route.traceId
135
+ return String((events[0] as Record<string, unknown> | undefined)?.trace_id ?? "")
136
+ })
137
+
138
+ const CONTEXT_RADIUS = 2
139
+
140
+ $effect(() => {
141
+ globalSearch
142
+ globalSeverity
143
+ contextAnchorIndex = null
144
+ })
145
+
146
+ $effect(() => {
147
+ const mq = window.matchMedia(`(min-width: ${SPLIT_MIN_PX}px)`)
148
+ const sync = (): void => {
149
+ layoutWide = mq.matches
150
+ }
151
+ sync()
152
+ mq.addEventListener("change", sync)
153
+ return () => mq.removeEventListener("change", sync)
154
+ })
155
+
156
+ /** If viewport becomes narrow while split view had a selection, fall back to full-screen events list. */
157
+ $effect(() => {
158
+ if (layoutWide) return
159
+ if (route.screen !== "interactions" || !splitSelectedKey) return
160
+ const tid = events[0] && String((events[0] as Record<string, unknown>).trace_id ?? "")
161
+ if (tid) route = { screen: "events", traceId: tid }
162
+ })
163
+
164
+ function severityRank(s: "none" | "error" | "warn"): number {
165
+ if (s === "error") return 2
166
+ if (s === "warn") return 1
167
+ return 0
168
+ }
169
+
170
+ function applyPulse(next: "error" | "warn"): void {
171
+ if (pulseTimer) clearTimeout(pulseTimer)
172
+ if (severityRank(next) >= severityRank(pulseKind)) pulseKind = next
173
+ pulseTimer = setTimeout(() => {
174
+ pulseKind = "none"
175
+ pulseTimer = null
176
+ }, PULSE_MS)
177
+ }
178
+
179
+ function processRecentForPulse(rows: Record<string, unknown>[]): void {
180
+ const nextKeys = new Set<string>()
181
+ rows.forEach((e, i) => nextKeys.add(eventKey(e, i)))
182
+
183
+ if (firstRecentPoll) {
184
+ firstRecentPoll = false
185
+ prevRecentKeys = nextKeys
186
+ return
187
+ }
188
+
189
+ let sawNewError = false
190
+ let sawNewWarn = false
191
+ for (let i = 0; i < rows.length; i++) {
192
+ const e = rows[i]
193
+ const k = eventKey(e, i)
194
+ if (!prevRecentKeys.has(k)) {
195
+ const sev = e.severity
196
+ if (sev === "error" || sev === "fatal") sawNewError = true
197
+ else if (sev === "warn") sawNewWarn = true
198
+ }
199
+ }
200
+ prevRecentKeys = nextKeys
201
+ if (sawNewError) applyPulse("error")
202
+ else if (sawNewWarn) applyPulse("warn")
203
+ }
204
+
205
+ function countsFromRecent(rows: Record<string, unknown>[]): {
206
+ total: number
207
+ err: number
208
+ warn: number
209
+ } {
210
+ let err = 0
211
+ let warn = 0
212
+ for (const e of rows) {
213
+ const s = e.severity
214
+ if (s === "error" || s === "fatal") err++
215
+ else if (s === "warn") warn++
216
+ }
217
+ return { total: rows.length, err, warn }
218
+ }
219
+
220
+ async function pollRecent(): Promise<void> {
221
+ try {
222
+ const rows = await fetchRecent()
223
+ recentEvents = rows
224
+ processRecentForPulse(rows)
225
+ loadError = null
226
+ } catch {
227
+ /* ignore transient poll failures */
228
+ }
229
+ }
230
+
231
+ async function loadInteractionsList(): Promise<void> {
232
+ try {
233
+ interactions = await fetchInteractions(source)
234
+ loadError = null
235
+ } catch (e) {
236
+ loadError = String(e)
237
+ }
238
+ }
239
+
240
+ function goTabProblems(): void {
241
+ splitSelectedKey = null
242
+ contextAnchorIndex = null
243
+ route = { screen: "problems" }
244
+ }
245
+
246
+ function goTabInteractions(): void {
247
+ splitSelectedKey = null
248
+ contextAnchorIndex = null
249
+ histogramTimeRange = null
250
+ events = []
251
+ route = { screen: "interactions" }
252
+ void loadInteractionsList()
253
+ }
254
+
255
+ async function onInteractionRowClick(row: Record<string, unknown>): Promise<void> {
256
+ const ids = row.trace_ids as string[] | undefined
257
+ const tid = ids?.[0]
258
+ if (!tid) return
259
+ const key = interactionRowKey(row)
260
+ try {
261
+ contextAnchorIndex = null
262
+ events = await fetchTraceEvents(tid)
263
+ if (layoutWide) {
264
+ splitSelectedKey = key
265
+ route = { screen: "interactions" }
266
+ } else {
267
+ splitSelectedKey = null
268
+ route = { screen: "events", traceId: tid }
269
+ }
270
+ } catch (e) {
271
+ loadError = String(e)
272
+ }
273
+ }
274
+
275
+ function openProblemDetail(ev: Record<string, unknown>): void {
276
+ const tid = String(ev.trace_id ?? "")
277
+ route = { screen: "detail", traceId: tid, event: ev, detailFrom: "problems" }
278
+ }
279
+
280
+ function selectEvent(ev: Record<string, unknown>, indexInFiltered: number): void {
281
+ const tid = String(ev.trace_id ?? "")
282
+ contextAnchorIndex = indexInFiltered
283
+ if (route.screen === "events") {
284
+ route = { screen: "detail", traceId: route.traceId, event: ev, detailFrom: "events" }
285
+ return
286
+ }
287
+ if (route.screen === "interactions" && layoutWide && splitSelectedKey) {
288
+ route = { screen: "detail", traceId: tid, event: ev, detailFrom: "events" }
289
+ }
290
+ }
291
+
292
+ function back(): void {
293
+ if (route.screen === "detail") {
294
+ if (route.detailFrom === "problems") {
295
+ route = { screen: "problems" }
296
+ } else if (layoutWide && splitSelectedKey) {
297
+ route = { screen: "interactions" }
298
+ } else {
299
+ route = { screen: "events", traceId: route.traceId }
300
+ }
301
+ return
302
+ }
303
+ if (route.screen === "events") {
304
+ splitSelectedKey = null
305
+ route = { screen: "interactions" }
306
+ }
307
+ }
308
+
309
+ function fabClick(e: MouseEvent): void {
310
+ const el = e.currentTarget
311
+ if (panelOpen) {
312
+ if (el instanceof HTMLButtonElement) panelCircleOrigin = originFromFabButton(el)
313
+ panelOpen = false
314
+ return
315
+ }
316
+ if (el instanceof HTMLButtonElement) {
317
+ panelCircleOrigin = originFromFabButton(el)
318
+ } else {
319
+ panelCircleOrigin = originFallbackFabCorner()
320
+ }
321
+ panelOpen = true
322
+ splitSelectedKey = null
323
+ const { err } = countsFromRecent(recentEvents)
324
+ if (err > 0) {
325
+ route = { screen: "problems" }
326
+ } else {
327
+ histogramTimeRange = null
328
+ route = { screen: "interactions" }
329
+ }
330
+ void loadInteractionsList()
331
+ }
332
+
333
+ async function copyText(text: string): Promise<void> {
334
+ try {
335
+ await navigator.clipboard.writeText(text)
336
+ } catch {
337
+ /* ignore */
338
+ }
339
+ }
340
+
341
+ async function copyDetailJson(): Promise<void> {
342
+ if (route.screen !== "detail") return
343
+ await copyText(JSON.stringify(route.event, null, 2))
344
+ }
345
+
346
+ async function copyDetailTraceId(): Promise<void> {
347
+ if (route.screen !== "detail") return
348
+ const tid = String(route.event.trace_id ?? "")
349
+ if (tid) await copyText(tid)
350
+ }
351
+
352
+ async function copyDetailRequestId(): Promise<void> {
353
+ if (route.screen !== "detail") return
354
+ const m = route.event.metadata as Record<string, unknown> | undefined
355
+ const rid = m && typeof m.request_id === "string" ? m.request_id : ""
356
+ if (rid) await copyText(rid)
357
+ }
358
+
359
+ function toggleRowExpand(key: string, e: MouseEvent): void {
360
+ e.stopPropagation()
361
+ rowExpanded = { ...rowExpanded, [key]: !rowExpanded[key] }
362
+ }
363
+
364
+ function payloadSummary(ev: Record<string, unknown>): string {
365
+ const p = ev.payload
366
+ if (p && typeof p === "object" && !Array.isArray(p)) {
367
+ const o = p as Record<string, unknown>
368
+ const msg = o.message
369
+ if (typeof msg === "string" && msg.length > 0) {
370
+ return msg.length > 140 ? `${msg.slice(0, 137)}…` : msg
371
+ }
372
+ }
373
+ try {
374
+ const s = JSON.stringify(p)
375
+ return s.length > 120 ? `${s.slice(0, 117)}…` : s
376
+ } catch {
377
+ return ""
378
+ }
379
+ }
380
+
381
+ function isContextNeighbor(indexInFiltered: number): boolean {
382
+ if (contextAnchorIndex === null) return false
383
+ if (!activeTraceId) return false
384
+ return Math.abs(indexInFiltered - contextAnchorIndex) <= CONTEXT_RADIUS
385
+ }
386
+
387
+ let panelTitle = $derived.by(() => {
388
+ if (route.screen === "problems") return "e11y — problems"
389
+ if (route.screen === "interactions") return "e11y — interactions"
390
+ if (route.screen === "events") return `e11y — trace ${route.traceId}`
391
+ if (route.screen === "detail") return `e11y — ${String(route.event.event_name ?? "event")}`
392
+ return "e11y"
393
+ })
394
+
395
+ let badgeLabel = $derived.by(() => {
396
+ const { total, err, warn } = countsFromRecent(recentEvents)
397
+ if (total === 0) return "e11y"
398
+ const parts: string[] = [`e11y ${total}`]
399
+ if (warn) parts.push(`${warn}⚠`)
400
+ if (err) parts.push(`${err}✕`)
401
+ return parts.join(" · ")
402
+ })
403
+
404
+ let fabStateClass = $derived.by((): "" | "e11y-fab--state-warn" | "e11y-fab--state-err" => {
405
+ const { err, warn } = countsFromRecent(recentEvents)
406
+ if (err) return "e11y-fab--state-err"
407
+ if (warn) return "e11y-fab--state-warn"
408
+ return ""
409
+ })
410
+
411
+ let fabPulseClass = $derived.by((): "" | "e11y-fab--pulse-error" | "e11y-fab--pulse-warn" => {
412
+ if (pulseKind === "error") return "e11y-fab--pulse-error"
413
+ if (pulseKind === "warn") return "e11y-fab--pulse-warn"
414
+ return ""
415
+ })
416
+
417
+ let tabProblemsActive = $derived.by(
418
+ () => route.screen === "problems" || (route.screen === "detail" && route.detailFrom === "problems")
419
+ )
420
+ let tabInteractionsActive = $derived.by(
421
+ () =>
422
+ route.screen === "interactions" ||
423
+ route.screen === "events" ||
424
+ (route.screen === "detail" && route.detailFrom === "events")
425
+ )
426
+
427
+ let showTraceFilters = $derived.by(
428
+ () =>
429
+ route.screen === "events" ||
430
+ (route.screen === "interactions" && layoutWide && !!splitSelectedKey)
431
+ )
432
+
433
+ let errCount = $derived.by(() => countsFromRecent(recentEvents).err)
434
+
435
+ function sevClass(sev: unknown): string {
436
+ const s = String(sev ?? "info")
437
+ if (s === "error" || s === "fatal") return "e11y-sev--error"
438
+ if (s === "warn") return "e11y-sev--warn"
439
+ return "e11y-sev--info"
440
+ }
441
+
442
+ function interactionRowKey(row: Record<string, unknown>): string {
443
+ const ids = (row.trace_ids as string[] | undefined) ?? []
444
+ return `${row.started_at ?? ""}|${ids.join(",")}`
445
+ }
446
+
447
+ function sourcePillClass(src: unknown): string {
448
+ const s = String(src ?? "")
449
+ if (s === "web") return "e11y-pill e11y-pill--web"
450
+ if (s === "job") return "e11y-pill e11y-pill--job"
451
+ return "e11y-pill"
452
+ }
453
+
454
+ $effect(() => {
455
+ const id = setInterval(() => void pollRecent(), POLL_MS)
456
+ void pollRecent()
457
+ return () => clearInterval(id)
458
+ })
459
+
460
+ $effect(() => {
461
+ if (!panelOpen) return
462
+ void loadInteractionsList()
463
+ })
464
+
465
+ $effect(() => {
466
+ source
467
+ if (panelOpen) void loadInteractionsList()
468
+ })
469
+ </script>
470
+
471
+ <div class="e11y-dt">
472
+ <Fab label={badgeLabel} onclick={fabClick} stateClass={fabStateClass} pulseClass={fabPulseClass} />
473
+
474
+ <FullscreenPanel
475
+ open={panelOpen}
476
+ onclose={() => (panelOpen = false)}
477
+ origin={panelCircleOrigin}
478
+ >
479
+ {#snippet headerTopLeft()}
480
+ {#if route.screen === "events" || route.screen === "detail"}
481
+ <button type="button" class="e11y-icon-btn" onclick={back} aria-label="Back" title="Go back" style="margin-right: -4px;">
482
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
483
+ </button>
484
+ {/if}
485
+
486
+ <div class="e11y-tab-row" role="tablist">
487
+ <button
488
+ type="button"
489
+ role="tab"
490
+ class="e11y-tab"
491
+ class:e11y-tab--active={tabProblemsActive}
492
+ aria-selected={tabProblemsActive}
493
+ onclick={goTabProblems}
494
+ >
495
+ Problems{#if errCount > 0}&nbsp;<span class="e11y-tab-badge">{errCount}</span>{/if}
496
+ </button>
497
+ <button
498
+ type="button"
499
+ role="tab"
500
+ class="e11y-tab"
501
+ class:e11y-tab--active={tabInteractionsActive}
502
+ aria-selected={tabInteractionsActive}
503
+ onclick={goTabInteractions}
504
+ >
505
+ Interactions
506
+ </button>
507
+ </div>
508
+
509
+ {#if route.screen === "events" || route.screen === "detail"}
510
+ <span class="e11y-breadcrumb-sep">/</span>
511
+ <span class="e11y-panel-title e11y-panel-title--sub" title={activeTraceId || undefined}>
512
+ {#if route.screen === "detail"}
513
+ Event Details
514
+ {:else}
515
+ Trace: {activeTraceId ? activeTraceId.slice(0, 8) + "…" : "Unknown"}
516
+ {/if}
517
+ </span>
518
+ {/if}
519
+ {/snippet}
520
+
521
+ {#snippet headerTopRight()}
522
+ {#if route.screen === "detail"}
523
+ <button type="button" class="e11y-btn" onclick={() => void copyDetailJson()}>Copy JSON</button>
524
+ <button type="button" class="e11y-btn" onclick={() => void copyDetailTraceId()}>Copy trace_id</button>
525
+ <button type="button" class="e11y-btn" onclick={() => void copyDetailRequestId()}>Copy request_id</button>
526
+ {:else if tabInteractionsActive && route.screen === "interactions"}
527
+ <div class="e11y-chip-row e11y-chip-row--header">
528
+ {#each ["web", "job", "all"] as s (s)}
529
+ <button
530
+ type="button"
531
+ class="e11y-chip"
532
+ class:e11y-chip--active={source === s}
533
+ onclick={() => (source = s as SourceFilter)}
534
+ >
535
+ {s}
536
+ </button>
537
+ {/each}
538
+ </div>
539
+ {/if}
540
+ {/snippet}
541
+
542
+ {#snippet headerBottom()}
543
+ <FilterBar
544
+ bind:search={globalSearch}
545
+ bind:severity={globalSeverity}
546
+ placeholder={
547
+ route.screen === "interactions"
548
+ ? "Search paths, methods, traces…"
549
+ : route.screen === "problems"
550
+ ? "Search problems, traces…"
551
+ : "Filter name, trace, payload…"
552
+ }
553
+ />
554
+ {/snippet}
555
+
556
+ {#snippet children()}
557
+ {#if loadError}
558
+ <p class="e11y-err-msg">{loadError}</p>
559
+ {/if}
560
+
561
+ {#if showTraceFilters}
562
+ {#if contextAnchorIndex !== null}
563
+ <p class="e11y-context-hint">
564
+ Context: ±{CONTEXT_RADIUS} rows around last opened event (change filter to clear).
565
+ </p>
566
+ {/if}
567
+ {/if}
568
+
569
+ {#if route.screen === "problems"}
570
+ <RecentHistogram bind:timeRange={histogramTimeRange} recent={recentEvents} />
571
+ <p class="e11y-problems-hint">Recent error / fatal events from the dev log (newest first).</p>
572
+ {#if problemEvents.length === 0}
573
+ <p class="e11y-muted e11y-empty">No errors in the recent buffer.</p>
574
+ <button type="button" class="e11y-btn" onclick={goTabInteractions}>Open interactions</button>
575
+ {:else if filteredProblemEvents.length === 0}
576
+ <p class="e11y-muted e11y-empty">
577
+ {#if histogramTimeRange && !globalSearch.trim()}
578
+ No errors in the selected time range. Widen the selection or clear it.
579
+ {:else if globalSearch.trim()}
580
+ No matches for this search (and current time range, if any).
581
+ {:else}
582
+ No matching errors.
583
+ {/if}
584
+ </p>
585
+ {:else}
586
+ {#each filteredProblemEvents as ev, i (eventKey(ev, i))}
587
+ {@const tr = String(ev.trace_id ?? "")}
588
+ {@const ek = eventKey(ev, i)}
589
+ {@const sum = payloadSummary(ev)}
590
+ <div
591
+ class="e11y-row e11y-row--problem"
592
+ role="button"
593
+ tabindex="0"
594
+ onclick={() => openProblemDetail(ev)}
595
+ onkeydown={(e) => e.key === "Enter" && openProblemDetail(ev)}
596
+ >
597
+ <button
598
+ type="button"
599
+ class="e11y-row-expand"
600
+ class:e11y-row-expand--open={rowExpanded[ek]}
601
+ aria-expanded={!!rowExpanded[ek]}
602
+ aria-label={rowExpanded[ek] ? "Collapse row" : "Expand row"}
603
+ onclick={(e) => toggleRowExpand(ek, e)}>▸</button
604
+ >
605
+ <span class="e11y-sev {sevClass(ev.severity)}">{String(ev.severity ?? "?")}</span>
606
+ <span class="e11y-row-title">{String(ev.event_name ?? "")}</span>
607
+ <span class="e11y-row-meta e11y-mono" title={tr || undefined}
608
+ >{tr.length > 14 ? `${tr.slice(0, 12)}…` : tr || "—"}</span
609
+ >
610
+ <span class="e11y-row-meta">{String(ev.timestamp ?? "")}</span>
611
+ {#if rowExpanded[ek]}
612
+ <div class="e11y-row-body">
613
+ {#if sum}<p class="e11y-row-sum">{sum}</p>{/if}
614
+ <pre class="e11y-row-pre">{JSON.stringify(ev.payload, null, 2)}</pre>
615
+ </div>
616
+ {/if}
617
+ </div>
618
+ {/each}
619
+ {/if}
620
+ {:else if route.screen === "interactions"}
621
+ <InteractionsTimeline bind:timeRange={interactionsTimeRange} {interactions} />
622
+ {#if layoutWide}
623
+ <div class="e11y-split">
624
+ <div class="e11y-split-primary">
625
+ {#if interactions.length === 0}
626
+ <p class="e11y-muted e11y-empty">No interactions recorded yet.</p>
627
+ {:else if filteredInteractions.length === 0}
628
+ <p class="e11y-muted e11y-empty">No interactions in the selected time range.</p>
629
+ {:else}
630
+ {#each filteredInteractions as row (interactionRowKey(row))}
631
+ {@const ids = (row.trace_ids as string[] | undefined) ?? []}
632
+ {@const tc = Number(row.traces_count ?? ids.length)}
633
+ {@const { absolute, relative } = formatInteractionStarted(String(row.started_at ?? ""))}
634
+ {@const { primary, extra, preview } = summarizeTraceIds(ids)}
635
+ {@const ikey = interactionRowKey(row)}
636
+ <div
637
+ class="e11y-ix"
638
+ class:e11y-ix--error={!!row.has_error}
639
+ class:e11y-ix--selected={splitSelectedKey === ikey}
640
+ role="button"
641
+ tabindex="0"
642
+ onclick={() => void onInteractionRowClick(row)}
643
+ onkeydown={(e) => e.key === "Enter" && void onInteractionRowClick(row)}
644
+ >
645
+ <div class="e11y-ix-main">
646
+ <div class="e11y-ix-time">
647
+ <span class="e11y-ix-time-abs">{absolute}</span>
648
+ {#if relative}
649
+ <span class="e11y-ix-time-rel">{relative}</span>
650
+ {/if}
651
+ </div>
652
+ <div class="e11y-ix-trace-line">
653
+ <code class="e11y-ix-trace-primary">{primary}</code>
654
+ {#if extra > 0}
655
+ <span class="e11y-muted">+{extra}</span>
656
+ {/if}
657
+ </div>
658
+ {#if preview && ids.length > 1}
659
+ <div class="e11y-ix-preview">{preview}</div>
660
+ {/if}
661
+ </div>
662
+ <div class="e11y-ix-aside">
663
+ <span class={sourcePillClass(row.source)}>{String(row.source ?? "?")}</span>
664
+ {#if row.has_error}
665
+ <span class="e11y-pill e11y-pill--err">err</span>
666
+ {/if}
667
+ <span class="e11y-ix-count">{tc}×</span>
668
+ </div>
669
+ </div>
670
+ {/each}
671
+ {/if}
672
+ </div>
673
+ <div class="e11y-split-secondary">
674
+ {#if !splitSelectedKey}
675
+ <p class="e11y-split-placeholder">Select an interaction to see events.</p>
676
+ {:else if filteredTraceEvents.length === 0}
677
+ <p class="e11y-muted e11y-split-placeholder">No events match the current filter.</p>
678
+ {:else}
679
+ {#each filteredTraceEvents as ev, j (eventKey(ev, j))}
680
+ {@const ek = eventKey(ev, j)}
681
+ {@const sum = payloadSummary(ev)}
682
+ <div
683
+ class="e11y-row"
684
+ class:e11y-row--context={isContextNeighbor(j)}
685
+ role="button"
686
+ tabindex="0"
687
+ onclick={() => selectEvent(ev, j)}
688
+ onkeydown={(e) => e.key === "Enter" && selectEvent(ev, j)}
689
+ >
690
+ <button
691
+ type="button"
692
+ class="e11y-row-expand"
693
+ class:e11y-row-expand--open={rowExpanded[ek]}
694
+ aria-expanded={!!rowExpanded[ek]}
695
+ aria-label={rowExpanded[ek] ? "Collapse row" : "Expand row"}
696
+ onclick={(e) => toggleRowExpand(ek, e)}>▸</button
697
+ >
698
+ <span class="e11y-sev {sevClass(ev.severity)}">{String(ev.severity ?? "?")}</span>
699
+ <span class="e11y-row-title">{String(ev.event_name ?? "")}</span>
700
+ <span class="e11y-row-meta">{String(ev.timestamp ?? "")}</span>
701
+ {#if rowExpanded[ek]}
702
+ <div class="e11y-row-body">
703
+ {#if sum}<p class="e11y-row-sum">{sum}</p>{/if}
704
+ <pre class="e11y-row-pre">{JSON.stringify(ev.payload, null, 2)}</pre>
705
+ </div>
706
+ {/if}
707
+ </div>
708
+ {/each}
709
+ {/if}
710
+ </div>
711
+ </div>
712
+ {:else}
713
+ {#if interactions.length === 0}
714
+ <p class="e11y-muted e11y-empty">No interactions recorded yet.</p>
715
+ {:else if filteredInteractions.length === 0}
716
+ <p class="e11y-muted e11y-empty">No interactions in the selected time range.</p>
717
+ {:else}
718
+ {#each filteredInteractions as row (interactionRowKey(row))}
719
+ {@const ids = (row.trace_ids as string[] | undefined) ?? []}
720
+ {@const tc = Number(row.traces_count ?? ids.length)}
721
+ {@const { absolute, relative } = formatInteractionStarted(String(row.started_at ?? ""))}
722
+ {@const { primary, extra, preview } = summarizeTraceIds(ids)}
723
+ <div
724
+ class="e11y-ix"
725
+ class:e11y-ix--error={!!row.has_error}
726
+ role="button"
727
+ tabindex="0"
728
+ onclick={() => void onInteractionRowClick(row)}
729
+ onkeydown={(e) => e.key === "Enter" && void onInteractionRowClick(row)}
730
+ >
731
+ <div class="e11y-ix-main">
732
+ <div class="e11y-ix-time">
733
+ <span class="e11y-ix-time-abs">{absolute}</span>
734
+ {#if relative}
735
+ <span class="e11y-ix-time-rel">{relative}</span>
736
+ {/if}
737
+ </div>
738
+ <div class="e11y-ix-trace-line">
739
+ <code class="e11y-ix-trace-primary" title="First trace_id">{primary}</code>
740
+ {#if extra > 0}
741
+ <span class="e11y-muted">+{extra} parallel</span>
742
+ {/if}
743
+ </div>
744
+ {#if preview && ids.length > 1}
745
+ <div class="e11y-ix-preview" title="Trace ids in group">{preview}</div>
746
+ {/if}
747
+ <div class="e11y-ix-hint">Click → events for first trace</div>
748
+ </div>
749
+ <div class="e11y-ix-aside">
750
+ <span class={sourcePillClass(row.source)}>{String(row.source ?? "?")}</span>
751
+ {#if row.has_error}
752
+ <span class="e11y-pill e11y-pill--err">Has errors</span>
753
+ {:else}
754
+ <span class="e11y-pill e11y-pill--ok">Clean</span>
755
+ {/if}
756
+ <span class="e11y-ix-count" title="Traces in group">{tc} trace{tc === 1 ? "" : "s"}</span>
757
+ </div>
758
+ </div>
759
+ {/each}
760
+ {/if}
761
+ {/if}
762
+ {:else if route.screen === "events"}
763
+ {#if filteredTraceEvents.length === 0}
764
+ <p class="e11y-muted e11y-empty">No events match the current filter.</p>
765
+ {:else}
766
+ {#each filteredTraceEvents as ev, i (eventKey(ev, i))}
767
+ {@const ek = eventKey(ev, i)}
768
+ {@const sum = payloadSummary(ev)}
769
+ <div
770
+ class="e11y-row"
771
+ class:e11y-row--context={isContextNeighbor(i)}
772
+ role="button"
773
+ tabindex="0"
774
+ onclick={() => selectEvent(ev, i)}
775
+ onkeydown={(e) => e.key === "Enter" && selectEvent(ev, i)}
776
+ >
777
+ <button
778
+ type="button"
779
+ class="e11y-row-expand"
780
+ class:e11y-row-expand--open={rowExpanded[ek]}
781
+ aria-expanded={!!rowExpanded[ek]}
782
+ aria-label={rowExpanded[ek] ? "Collapse row" : "Expand row"}
783
+ onclick={(e) => toggleRowExpand(ek, e)}>▸</button
784
+ >
785
+ <span class="e11y-sev {sevClass(ev.severity)}">{String(ev.severity ?? "?")}</span>
786
+ <span class="e11y-row-title">{String(ev.event_name ?? "")}</span>
787
+ <span class="e11y-row-meta">{String(ev.timestamp ?? "")}</span>
788
+ {#if rowExpanded[ek]}
789
+ <div class="e11y-row-body">
790
+ {#if sum}<p class="e11y-row-sum">{sum}</p>{/if}
791
+ <pre class="e11y-row-pre">{JSON.stringify(ev.payload, null, 2)}</pre>
792
+ </div>
793
+ {/if}
794
+ </div>
795
+ {/each}
796
+ {/if}
797
+ {:else if route.screen === "detail"}
798
+ {@const d = route.event}
799
+ {@const meta = d.metadata as Record<string, unknown> | undefined}
800
+ <div class="e11y-detail">
801
+ <dl class="e11y-detail-dl">
802
+ <dt>trace_id</dt>
803
+ <dd class="e11y-mono">{String(d.trace_id ?? "—")}</dd>
804
+ <dt>span_id</dt>
805
+ <dd class="e11y-mono">{String(d.span_id ?? "—")}</dd>
806
+ <dt>request_id</dt>
807
+ <dd class="e11y-mono">{String(meta?.request_id ?? "—")}</dd>
808
+ <dt>timestamp</dt>
809
+ <dd>{String(d.timestamp ?? "—")}</dd>
810
+ </dl>
811
+ <details class="e11y-details">
812
+ <summary>payload</summary>
813
+ <pre class="e11y-detail-pre">{JSON.stringify(d.payload, null, 2)}</pre>
814
+ </details>
815
+ <details class="e11y-details">
816
+ <summary>metadata</summary>
817
+ <pre class="e11y-detail-pre">{JSON.stringify(d.metadata ?? {}, null, 2)}</pre>
818
+ </details>
819
+ <details class="e11y-details">
820
+ <summary>full JSON</summary>
821
+ <pre class="e11y-detail-pre">{JSON.stringify(d, null, 2)}</pre>
822
+ </details>
823
+ </div>
824
+ {/if}
825
+ {/snippet}
826
+ </FullscreenPanel>
827
+ </div>