wide_events 0.1.4 → 0.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab8bbc27c3a72a3efd7dbb3cb63453936a5f520b0a72e50ca8c89fa79d296a0b
4
- data.tar.gz: 28a32dc6e3c7d98ac4b795c012e36ea8e02eb8a72dd4115f08ad7c808e27f251
3
+ metadata.gz: ea7b42dac2182e0b1c22f607629658f019943cf773cfc0398cdcfedbf9d4b311
4
+ data.tar.gz: 300d00038776858fce62fa359e9f08f6432d2fe3955fbf8b45f9a10480c73da5
5
5
  SHA512:
6
- metadata.gz: f27aefb767cff731bc999fdec722d2a2ea0195dcd4e8d9d37b6f406c85725eed9a4594d279554a58fe27cdb8c5a92ac33f043ff9748439d546fb0825c3eba382
7
- data.tar.gz: a3f1b8b6c52d66a045d08284fc101b036470509c571696f2f324c42451225db2f8e0dfaacac431aa2b966e8250761321f799387ab778c9c43a73f002497de6e9
6
+ metadata.gz: 393e3e6d00481ddc6500e4f1d9e1dd4d332f3d97a027b80e13008e8dec15ef5dc95e3a8a56d10780fbb847cb23fd7173d36764a3ec20489ca643c574e2496902
7
+ data.tar.gz: 22c83a1f6b26e27e0ede2e6e79aaa4cab933f188792e499e4c608844259d7c331f7f608bfcdf5d8456d9e1f7881b2fd1fdfe7b9e8e62cc7970baaf652ca3460c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.1 (2026-08-06)
4
+
5
+ - Fix the store image pin in the README. The v0.2.0 release rewrite turned
6
+ `ghcr.io/adammiribyan/wide-events-store:0.2.0` into `.2.0`.
7
+
8
+ ## v0.2.0 (2026-08-06)
9
+
10
+ - **A bundled telemetry store.** `bin/rails generate wide_events:store
11
+ --host --hostname --retention-days` writes a Kamal accessory for
12
+ `ghcr.io/adammiribyan/wide-events-store:<version>`, a single-process Go
13
+ service around DuckDB, plus durable ingest and query tokens under
14
+ `.kamal/`. `kamal setup` deploys it and `kamal telemetry` opens a SQL
15
+ prompt against production. No collector and no OpenTelemetry SDK
16
+ required; `:otel` and `:log` keep working unchanged.
17
+ - The `:store` sink batches, gzips, and delivers events on one background
18
+ thread per process, behind a bounded queue (1,000 events or 8 MiB) that
19
+ drops oldest-first and reports the count with the next batch. Batches are
20
+ idempotent by `batch_id`, so a response lost after the store committed is
21
+ retried and deduplicated instead of double-counted. The sender is
22
+ fork-aware.
23
+ - `WIDE_EVENTS_URL` alone enables the gem and selects the store sink,
24
+ ahead of `OTEL_EXPORTER_OTLP_ENDPOINT`. `WIDE_EVENTS_SERVICE`,
25
+ `WIDE_EVENTS_ENVIRONMENT`, `WIDE_EVENTS_INGEST_TOKEN`, and
26
+ `WIDE_EVENTS_QUERY_TOKEN` complete the configuration; ingest and query
27
+ tokens must differ.
28
+ - The store keeps `route`, `job_class`, `status`, `error`, `request_id`,
29
+ `duration_ms`, `kind`, `deployment`, and `source` as real columns and
30
+ everything else in an `attributes` VARIANT, read as
31
+ `attributes['key.name']::VARCHAR`. `wide_events_store_status` is the
32
+ one-row operational view. Queries are read-only and bounded: 2 running,
33
+ 4 waiting, 15s, 10,000 rows, 10 MiB.
34
+ - Retention deletes events past `WIDE_EVENTS_RETENTION_DAYS` daily, a
35
+ verified local backup runs alongside it (three newest kept, or on demand
36
+ via `kamal wide-events-backup`), and the store reserves the greater of
37
+ 2 GiB or 10% of the volume as a write floor. Below it, ingest answers 507
38
+ while `/up` and queries keep working.
39
+ - `bin/rails wide_events:setup:check` is the idempotent resume command for
40
+ the whole setup: Kamal version and config, host directory permissions,
41
+ DNS, and a synthetic round trip through the deployed endpoint.
42
+ - A third bundled skill, `setting-up-wide-events-store`, walks an agent
43
+ through the deploy. `debugging-with-wide-events` gained the store's data
44
+ model and standing queries.
45
+ - The generated accessory raises `minimum_version` to `2.10.0`: its
46
+ structured `directories` entry carries the `mode` and `owner` the DuckDB
47
+ volume needs, and older Kamal releases reject it.
48
+
3
49
  ## v0.1.4 (2026-08-04)
4
50
 
5
51
  - The railtie stores `registry_path` relative to the app root
data/README.md CHANGED
@@ -5,7 +5,13 @@
5
5
 
6
6
  One wide telemetry event per Rails request or job execution, in a database you own.
7
7
 
8
- Wide Events collects everything your app knows about each unit of work (route, user, account, build SHA, query counts, cache hits, feature flags, phase timings, errors) into one flat, high-cardinality event on the OpenTelemetry root span you already export. Every request becomes one row, and every question becomes one query against storage you run. ClickHouse with HyperDX is a proven pairing (both open source); any OTLP backend works. If you'd rather not run tracing, the log sink emits one JSON event per request.
8
+ Wide Events collects everything your app knows about each unit of work (route, user, account, build SHA, query counts, cache hits, feature flags, phase timings, errors) into one flat, high-cardinality event. Every request becomes one row, and every question becomes one query against storage you run.
9
+
10
+ Three places to put those rows, in order of how much you have to operate:
11
+
12
+ - **The bundled store.** A DuckDB service the gem ships as a Kamal accessory: one generator, one `kamal setup`, and `kamal telemetry` gives you a SQL prompt against production. No collector, no OTel SDK.
13
+ - **Any OTLP backend.** The event goes onto the OpenTelemetry root span you already export. ClickHouse with HyperDX is a proven pairing (both open source).
14
+ - **Your log store.** The log sink emits one JSON event per request.
9
15
 
10
16
  That format matters more now that agents build with you. Telemetry stops being something humans only glance at and becomes something software queries repeatedly: an agent can instrument a feature, deploy it, and verify it in production. One row per request is a dense way to feed production behavior back into a context window, and owning the storage avoids metered per-event and per-query observability fees.
11
17
 
@@ -41,9 +47,130 @@ grep '"main":true' log/development.log
41
47
 
42
48
  One JSON object per request: route, status, timings, query counts, everything.
43
49
 
50
+ ## Production in ten minutes: the bundled store
51
+
52
+ The gem ships its own telemetry database: a single Go process around DuckDB, deployed as a Kamal accessory on a VM you own. No collector, no OTel SDK, no vendor account. One `bin/rails generate` writes the accessory, `kamal setup` deploys it, and `kamal telemetry` opens a SQL prompt against production.
53
+
54
+ ```bash
55
+ bin/rails generate wide_events:store --host=203.0.113.20 --hostname=telemetry.example.com --retention-days=30
56
+ ```
57
+
58
+ `--host` is the SSH address of the VM that will run the store. `--hostname` is the public DNS name the app and your SQL prompt reach it through; point an A record at the host now, so propagation happens while you finish the rest. The generator edits `config/deploy.yml` and writes two 64-hex tokens to `.kamal/wide-events-ingest-token` and `.kamal/wide-events-query-token` (mode 0600, gitignored, wired into `.kamal/secrets`). Every edit is marked and idempotent, so re-running it is safe. If your `config/deploy.yml` is shaped in a way the editor won't touch, it refuses and prints the complete block to merge by hand.
59
+
60
+ Here's the accessory it adds:
61
+
62
+ ```yaml
63
+ accessories:
64
+ wide_events:
65
+ image: ghcr.io/adammiribyan/wide-events-store:0.2.1
66
+ host: 203.0.113.20
67
+
68
+ proxy:
69
+ host: telemetry.example.com
70
+ ssl: true
71
+ app_port: 7421
72
+ healthcheck:
73
+ path: /up
74
+ interval: 3
75
+ timeout: 3
76
+
77
+ env:
78
+ clear:
79
+ WIDE_EVENTS_LISTEN: 0.0.0.0:7421
80
+ WIDE_EVENTS_DATABASE: /var/lib/wide-events/events.duckdb
81
+ WIDE_EVENTS_SERVICE: myapp
82
+ WIDE_EVENTS_ENVIRONMENT: production
83
+ WIDE_EVENTS_RETENTION_DAYS: 30
84
+ secret:
85
+ - WIDE_EVENTS_INGEST_TOKEN
86
+ - WIDE_EVENTS_QUERY_TOKEN
87
+
88
+ directories:
89
+ - local: /var/lib/myapp-wide-events
90
+ remote: /var/lib/wide-events
91
+ mode: "0750"
92
+ owner: "1000:1000"
93
+
94
+ options:
95
+ cpus: 2
96
+ memory: 2g
97
+ ```
98
+
99
+ The structured `directories` entry is why the generator also raises `minimum_version` to `2.10.0`: older Kamal releases only accept a `local:remote` string and would mount the DuckDB volume without the mode and owner the container's uid/gid 1000 needs. On the app side it sets `WIDE_EVENTS_URL`, `WIDE_EVENTS_SERVICE`, and `WIDE_EVENTS_ENVIRONMENT` in `env.clear` and binds both tokens as secrets. Setting `WIDE_EVENTS_URL` is all it takes to switch the sink: the gem enables itself and sends to the store instead of an OTel span.
100
+
101
+ Then, in order:
102
+
103
+ ```bash
104
+ bin/rails wide_events:setup:check # Kamal version, config, host directory, DNS, endpoint
105
+ kamal setup
106
+ bin/rails wide_events:setup:check # sends one synthetic event and queries it back
107
+ ```
108
+
109
+ `wide_events:setup:check` is the same command at every stage. It reports `failed`, `needs_dns`, `ready_to_deploy`, `degraded`, or `healthy`, and `needs_dns` is expected while an A record propagates: nothing is lost, run it again later. On a healthy store it writes one synthetic event, reads it back by id, and prints a route/p95 query, so you know ingest token, query token, TLS, and DuckDB all work before real traffic arrives.
110
+
111
+ Working with an agent? `bin/rails generate wide_events:skills` installs the `setting-up-wide-events-store` skill, which walks an agent through this whole flow, including what to say about DNS waits and backup limitations.
112
+
113
+ ## Querying the store
114
+
115
+ ```bash
116
+ kamal telemetry # interactive SQL prompt
117
+ echo "SELECT count(*) FROM wide_events" | kamal telemetry
118
+ ```
119
+
120
+ Output is a text table by default. `bin/rails wide_events:sql` reads `WIDE_EVENTS_FORMAT` (`table`, `json`, or `csv`) from the environment it runs in, so set it in the app container, not in your local shell.
121
+
122
+ `route`, `job_class`, `status`, `error`, `request_id`, `duration_ms`, `kind`, `deployment`, `source`, `occurred_at`, and `received_at` are real columns. Everything else the app set lives in an `attributes` VARIANT column, read as `attributes['key.name']` and cast to the type you want:
123
+
124
+ ```sql
125
+ SELECT route,
126
+ count(*) AS requests,
127
+ quantile_cont(duration_ms, 0.5) AS p50_ms,
128
+ quantile_cont(duration_ms, 0.95) AS p95_ms
129
+ FROM wide_events
130
+ WHERE occurred_at > current_timestamp - INTERVAL 1 DAY
131
+ GROUP BY route
132
+ ORDER BY p95_ms DESC
133
+ LIMIT 15
134
+ ```
135
+
136
+ ```sql
137
+ -- Exceptions that escaped without a named handled failure
138
+ SELECT route, attributes['exception.type']::VARCHAR AS exception_type, count(*) AS occurrences
139
+ FROM wide_events
140
+ WHERE error
141
+ AND coalesce(attributes['exception.slug']::VARCHAR, '') = ''
142
+ AND occurred_at > current_timestamp - INTERVAL 1 DAY
143
+ GROUP BY route, exception_type
144
+ ORDER BY occurrences DESC
145
+ ```
146
+
147
+ `SELECT * FROM wide_events_store_status` is the one-row operational view: rows in the last hour and day, oldest and newest event, database and free-disk bytes, queued ingest bytes, rejected batches, reported dropped events, write readiness and why, last checkpoint/retention/backup times and errors, and the store, protocol, and DuckDB versions. Read it first whenever a query returns nothing.
148
+
149
+ Queries are read-only and bounded: two run at a time with four more allowed to wait, 15 seconds of execution, 2 seconds of queueing, 10,000 rows, and 10 MiB of response. A statement that isn't a single `SELECT` is rejected by DuckDB's own parser before it runs.
150
+
151
+ ## Two tokens, two jobs
152
+
153
+ The ingest token writes and the query token reads; the store refuses to start if they're equal. Sharing SQL access with a teammate or an agent means handing over `.kamal/wide-events-query-token` only, and never the ability to forge events. Both are 64 hex characters, generated once by the generator and reused on every later run.
154
+
155
+ ## Sizing, retention, and what happens when the disk fills
156
+
157
+ One event is roughly 1 KB before compression. At 30-day retention, 100 requests/second is about 250 GB of raw rows before DuckDB's columnar compression, which typically lands 5-10x smaller; 10 requests/second fits comfortably in tens of gigabytes. Start with a 100 GB volume and 2 GB of RAM, watch `database_file_bytes` in the status view for a week, and resize from evidence.
158
+
159
+ Retention runs once a day and deletes events older than `WIDE_EVENTS_RETENTION_DAYS`. The store reserves the greater of 2 GiB or 10% of the volume as a write floor. Below it, ingest answers `507` and the sender holds its batches, but `/up` still returns 200 and queries keep working, so you can diagnose a full disk from the store itself rather than by SSH.
160
+
161
+ Loss is bounded and reported, never silent. Each app process queues up to 1,000 events or 8 MiB; past that, the oldest are dropped and the count travels with the next batch into `wide_events_loss_reports`, so a gap in the data is visible as a number. Batches are idempotent by `batch_id`, so a response lost after the store committed is retried and deduplicated instead of double-counted.
162
+
163
+ ## Backups, upgrades, and rollback
164
+
165
+ A verified local backup runs daily and keeps the three newest copies in `backups/` next to the database. Take one on demand with `kamal wide-events-backup`, which prints the path it wrote.
166
+
167
+ Backups are local only. They protect against a bad upgrade or a mistaken `DELETE`, not against losing the VM or its volume; there's no off-host copy in v1. If that matters for your deployment, snapshot the host volume on your provider's schedule. Restoring is manual: stop the accessory, replace `/var/lib/wide-events/events.duckdb` with the backup file, and start it again. Nothing but the store process may open the database; the admin socket exists so backups happen through the running owner instead of a second process touching the file.
168
+
169
+ Upgrading is a gem bump. Each release publishes `ghcr.io/adammiribyan/wide-events-store:<version>` for linux/amd64 and linux/arm64. Re-run `bin/rails generate wide_events:store` with the same options to rewrite the pinned image, then `kamal accessory reboot wide_events` to pull it and restart. There is no `latest` tag on purpose, so an already-deployed accessory can't change under you on its next boot. Migrations are forward-only and run at startup: a store that finds a schema newer than it understands refuses to open rather than corrupting it. To roll back, pin the previous version by hand, and take a backup first if the newer release applied a migration.
170
+
44
171
  ## Production setup with OpenTelemetry
45
172
 
46
- The default sink writes the event onto the current OTel root span, which is the span OTel's Rack instrumentation opens at the top of the middleware stack. That means production needs an OpenTelemetry SDK configured; the gem detects it and wires itself after your initializers run. Minimal setup:
173
+ Prefer an existing OTel pipeline, or already run ClickHouse? Skip the store. The `:otel` sink writes the event onto the current OTel root span, which is the span OTel's Rack instrumentation opens at the top of the middleware stack. That means production needs an OpenTelemetry SDK configured; the gem detects it and wires itself after your initializers run. Minimal setup:
47
174
 
48
175
  ```ruby
49
176
  # Gemfile
@@ -167,18 +294,22 @@ The teardown pattern makes the registry check work with parallel test workers: v
167
294
 
168
295
  | Environment | Typical setup | Result |
169
296
  |---|---|---|
297
+ | Production / staging | `WIDE_EVENTS_URL` set (the store generator sets it) | Enabled automatically, `:store` sink, batched over HTTPS |
170
298
  | Production / staging | `OTEL_EXPORTER_OTLP_ENDPOINT` set | Enabled automatically, `:otel` sink, events on root spans |
171
299
  | Development | `config.enabled = true`, `config.sink = :log` | One JSON line per request in the log |
172
300
  | Test | Enabled via the test helper's capture, or strict mode for registry tracking | `capture_wide_events` collects them |
173
301
 
302
+ `WIDE_EVENTS_URL` wins over `OTEL_EXPORTER_OTLP_ENDPOINT` when both are set.
303
+
174
304
  `WideEvent.install!` runs once at boot, only if enabled at that point. Flipping `config.enabled` at runtime gates the middleware and job hook (they check per request), but the span counter and subscribers only register at boot, so treat enablement as a boot-time decision.
175
305
 
176
306
  ## Built for the agent loop
177
307
 
178
- `bin/rails generate wide_events:skills` installs two agent skills:
308
+ `bin/rails generate wide_events:skills` installs three agent skills:
179
309
 
180
310
  - **instrumenting-wide-events**: the write path. Naming conventions, when to use `set` vs `phase` vs `error!`, the registry workflow, PII rules, test assertions.
181
- - **debugging-with-wide-events**: the read path. Symptom-to-query workflow against ClickHouse/HyperDX or JSON logs, plus the standing queries worth running.
311
+ - **debugging-with-wide-events**: the read path. Symptom-to-query workflow against the DuckDB store, ClickHouse/HyperDX, or JSON logs, plus the standing queries worth running.
312
+ - **setting-up-wide-events-store**: the deploy path. Generator, DNS, `kamal setup`, and the checker states, end to end.
182
313
 
183
314
  Skills land in `.claude/skills/`, which Claude Code and Claude-compatible agents read. Other agents (Cursor, etc.) read the pointer the install generator appends to `AGENTS.md`; the SKILL.md files are plain markdown, so copy or symlink them wherever your tooling looks (`.cursor/rules/`, docs, a system prompt).
184
315
 
@@ -186,8 +317,13 @@ Skills land in `.claude/skills/`, which Claude Code and Claude-compatible agents
186
317
 
187
318
  ```ruby
188
319
  WideEvent.configure do |config|
189
- config.enabled = ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present? # default
190
- config.sink = :otel # :otel, :log, or any object responding to flush(attrs)
320
+ config.enabled = ENV["WIDE_EVENTS_URL"].present? || ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present? # default
321
+ config.sink = :store # :store, :otel, :log, or any object responding to flush(attrs)
322
+ config.store_url = ENV["WIDE_EVENTS_URL"] # HTTPS, except an explicit loopback URL
323
+ config.store_service = ENV["WIDE_EVENTS_SERVICE"]
324
+ config.store_environment = ENV["WIDE_EVENTS_ENVIRONMENT"]
325
+ config.store_ingest_token = ENV["WIDE_EVENTS_INGEST_TOKEN"]
326
+ config.store_query_token = ENV["WIDE_EVENTS_QUERY_TOKEN"]
191
327
  config.logger = nil # :log sink target; defaults to Rails.logger
192
328
  config.strict = Rails.env.test? # track attributes against the registry
193
329
  config.max_cache_attrs = 10
@@ -201,9 +337,11 @@ WideEvent.configure do |config|
201
337
  end
202
338
  ```
203
339
 
340
+ Store sink notes: every store setting defaults to its `WIDE_EVENTS_*` environment variable, which the Kamal generator already sets, so most apps configure nothing here. Delivery happens on one background thread per process; application threads only append to a bounded in-memory queue and never block on the network. The sender is fork-aware, so Puma and Sidekiq workers each get their own queue instead of inheriting the parent's.
341
+
204
342
  Log sink notes: events go to `config.logger` (or `Rails.logger`) at info level as single-line JSON. Point it at a dedicated logger (`config.logger = Logger.new("log/wide_events.log")`) to keep them out of your main log, and leave the sink off in the test environment unless you want one JSON line per test request; `capture_wide_events` is the intended test-side tap.
205
343
 
206
- ## Querying: ClickHouse + HyperDX
344
+ ## Querying an OTLP backend: ClickHouse + HyperDX
207
345
 
208
346
  [docs/clickhouse-hyperdx.md](https://github.com/adammiribyan/wide_events/blob/main/docs/clickhouse-hyperdx.md) has a local quickstart (one container) and a query cookbook. The flavor:
209
347
 
@@ -231,14 +369,17 @@ Flat keys, dot namespaces, snake_case leaves. Durations end in `_duration_ms`, c
231
369
  bin/setup # bundle install + appraisal gemfiles
232
370
  bundle exec rake test # run the suite
233
371
  bin/rubocop # lint (rubocop-rails-omakase)
372
+ cd store && go test ./... # the DuckDB store service
234
373
  ```
235
374
 
236
- The CI matrix runs the suite across Ruby 3.2 to 4.0 and Rails 7.1 to main via [Appraisal](https://github.com/thoughtbot/appraisal); run a specific combination locally with `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test`. Releases go out with `bin/release <version>`.
375
+ The CI matrix runs the suite across Ruby 3.2 to 4.0 and Rails 7.1 to main via [Appraisal](https://github.com/thoughtbot/appraisal); run a specific combination locally with `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test`. [CONTRIBUTING.md](https://github.com/adammiribyan/wide_events/blob/main/CONTRIBUTING.md) covers the store's Go, Docker, cross-runtime integration, and Kamal compatibility commands. Releases go out with `bin/release <version>`, which also publishes the matching store image.
237
376
 
238
377
  ## Requirements
239
378
 
240
379
  Ruby >= 3.2, Rails >= 7.1 (activesupport and rack are the only hard dependencies; opentelemetry-sdk and useragent are optional and detected at runtime).
241
380
 
381
+ The bundled store needs a Linux VM with Docker and Kamal >= 2.10.0. Images are published for linux/amd64 and linux/arm64.
382
+
242
383
  ## License
243
384
 
244
385
  MIT.
@@ -1,8 +1,11 @@
1
1
  require "rails/generators/base"
2
+ require "generators/wide_events/skill_installer"
2
3
 
3
4
  module WideEvents
4
5
  module Generators
5
6
  class InstallGenerator < Rails::Generators::Base
7
+ include WideEvents::Generators::SkillInstaller
8
+
6
9
  source_root File.expand_path("templates", __dir__)
7
10
 
8
11
  def create_initializer
@@ -13,10 +16,21 @@ module WideEvents
13
16
  copy_file "registry.yml", "config/wide_event/registry.yml"
14
17
  end
15
18
 
19
+ def install_skills
20
+ copy_wide_events_skills
21
+ end
22
+
23
+ # Idempotent: a second run of this generator must not duplicate the
24
+ # section in an AGENTS.md it already added it to.
16
25
  def add_agents_section
17
26
  section = File.read(File.expand_path("templates/agents_md_section.md", __dir__))
18
- if File.exist?(File.join(destination_root, "AGENTS.md"))
19
- append_to_file "AGENTS.md", "\n#{section}"
27
+ path = File.join(destination_root, "AGENTS.md")
28
+ if File.exist?(path)
29
+ if File.read(path).include?(section.strip)
30
+ say_status :identical, "AGENTS.md"
31
+ else
32
+ append_to_file "AGENTS.md", "\n#{section}"
33
+ end
20
34
  else
21
35
  create_file "AGENTS.md", section
22
36
  end
@@ -0,0 +1,24 @@
1
+ module WideEvents
2
+ module Generators
3
+ # Copies every bundled agent skill (skills/*/SKILL.md) into
4
+ # .claude/skills/<name>/SKILL.md. Shared by InstallGenerator (so a fresh
5
+ # `wide_events:install` sets up all three skills in one pass) and
6
+ # SkillsGenerator (a standalone way to add or refresh them later), so
7
+ # both stay byte-for-byte consistent and a new bundled skill only has to
8
+ # be wired up in one place.
9
+ #
10
+ # `copy_file` accepts an absolute source path regardless of the
11
+ # including generator's own `source_root`, so this mixes into either
12
+ # generator unchanged.
13
+ module SkillInstaller
14
+ SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
15
+
16
+ def copy_wide_events_skills
17
+ Dir[File.join(SKILLS_ROOT, "*", "SKILL.md")].sort.each do |path|
18
+ name = File.basename(File.dirname(path))
19
+ copy_file path, ".claude/skills/#{name}/SKILL.md"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,19 +1,21 @@
1
1
  require "rails/generators/base"
2
+ require "generators/wide_events/skill_installer"
2
3
 
3
4
  module WideEvents
4
5
  module Generators
5
6
  # Copies the agent skills shipped with the gem into .claude/skills/ so
6
- # coding agents pick up the instrumentation and debugging conventions.
7
+ # coding agents pick up the instrumentation, debugging, and store-setup
8
+ # conventions. A standalone way to add or refresh them later; a fresh
9
+ # `wide_events:install` already installs the same skills (see
10
+ # InstallGenerator), via the same WideEvents::Generators::SkillInstaller
11
+ # routine.
7
12
  class SkillsGenerator < Rails::Generators::Base
8
- SKILLS_ROOT = File.expand_path("../../../../skills", __dir__)
13
+ include WideEvents::Generators::SkillInstaller
9
14
 
10
- source_root SKILLS_ROOT
15
+ source_root SkillInstaller::SKILLS_ROOT
11
16
 
12
17
  def copy_skills
13
- Dir[File.join(SKILLS_ROOT, "*", "SKILL.md")].sort.each do |path|
14
- name = File.basename(File.dirname(path))
15
- copy_file File.join(name, "SKILL.md"), ".claude/skills/#{name}/SKILL.md"
16
- end
18
+ copy_wide_events_skills
17
19
  end
18
20
  end
19
21
  end
@@ -0,0 +1,145 @@
1
+ require "rails/generators/base"
2
+ require "yaml"
3
+ require "tempfile"
4
+ require "fileutils"
5
+ require "ipaddr"
6
+ require "wide_event/version"
7
+ require "wide_event/kamal/deploy_editor"
8
+ require "wide_event/kamal/secrets_editor"
9
+
10
+ module WideEvents
11
+ module Generators
12
+ # Wires the bundled wide_events store container into a host app's Kamal
13
+ # deployment: an accessory in config/deploy.yml plus durable ingest/query
14
+ # tokens under .kamal/. Delegates every structural decision to
15
+ # WideEvent::Kamal::DeployEditor and WideEvent::Kamal::SecretsEditor so
16
+ # this class only handles option parsing, Thor plumbing, and applying
17
+ # both edits as a single transaction: nothing is written to disk unless
18
+ # every preflight check on both editors has already succeeded.
19
+ class StoreGenerator < Rails::Generators::Base
20
+ source_root File.expand_path("templates", __dir__)
21
+
22
+ DNS_NAME_RE = /\A(?=.{1,253}\z)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/
23
+
24
+ class_option :host, type: :string, desc: "SSH host/IP of the store VM (IP address or DNS name)"
25
+ class_option :hostname, type: :string, desc: "Public DNS hostname for the telemetry endpoint (no scheme/path)"
26
+ class_option :"retention-days", type: :string, default: "30", desc: "Event retention window, in days"
27
+ class_option :service, type: :string, desc: "Kamal service name (defaults to config/deploy.yml's service)"
28
+ class_option :environment, type: :string, default: "production", desc: "WIDE_EVENTS_ENVIRONMENT value"
29
+
30
+ def generate_accessory
31
+ host = options["host"]
32
+ hostname = options["hostname"]
33
+ retention_days = options["retention-days"]
34
+
35
+ return unless valid_options?(host, hostname, retention_days)
36
+
37
+ deploy_path = File.join(destination_root, "config/deploy.yml")
38
+ unless File.exist?(deploy_path)
39
+ say_status :error, "config/deploy.yml not found - run `kamal init` first, then re-run this generator", :red
40
+ return
41
+ end
42
+
43
+ service = options["service"] || default_service_name(deploy_path)
44
+ image = "ghcr.io/adammiribyan/wide-events-store:#{WideEvent::VERSION}"
45
+
46
+ deploy_result = WideEvent::Kamal::DeployEditor.new(
47
+ File.read(deploy_path),
48
+ service: service,
49
+ host: host,
50
+ hostname: hostname,
51
+ environment: options["environment"],
52
+ retention_days: retention_days.to_i,
53
+ image: image
54
+ ).apply
55
+
56
+ if deploy_result.conflicts.any?
57
+ say_status :refused, "config/deploy.yml was left unchanged - merge this by hand:", :red
58
+ say deploy_result.snippet
59
+ return
60
+ end
61
+
62
+ begin
63
+ secrets_result = WideEvent::Kamal::SecretsEditor.new(root: destination_root).apply
64
+ rescue WideEvent::Kamal::SecretsEditor::UnsafeTokenFile => e
65
+ say_status :error, e.message, :red
66
+ return
67
+ end
68
+
69
+ if deploy_result.changed?
70
+ atomic_write(deploy_path, deploy_result.content)
71
+ say_status :update, "config/deploy.yml"
72
+ else
73
+ say_status :identical, "config/deploy.yml"
74
+ end
75
+
76
+ secrets_result.write!
77
+ say_status :kamal, "wide_events store accessory ready - run `kamal setup` then `kamal telemetry`"
78
+ end
79
+
80
+ private
81
+
82
+ def valid_options?(host, hostname, retention_days)
83
+ unless present?(host) && valid_host?(host)
84
+ say_status :error, "--host must be an IP address or DNS name, got #{host.inspect}", :red
85
+ return false
86
+ end
87
+
88
+ unless present?(hostname) && valid_hostname?(hostname)
89
+ say_status :error, "--hostname must be a bare DNS name without scheme or path, got #{hostname.inspect}", :red
90
+ return false
91
+ end
92
+
93
+ unless positive_integer?(retention_days)
94
+ say_status :error, "--retention-days must be a positive integer, got #{retention_days.inspect}", :red
95
+ return false
96
+ end
97
+
98
+ true
99
+ end
100
+
101
+ def present?(value)
102
+ !value.nil? && !value.strip.empty?
103
+ end
104
+
105
+ def valid_host?(value)
106
+ ip_address?(value) || DNS_NAME_RE.match?(value)
107
+ end
108
+
109
+ def valid_hostname?(value)
110
+ return false if value.include?("/") || value.include?(":") || value.include?("@")
111
+
112
+ DNS_NAME_RE.match?(value)
113
+ end
114
+
115
+ def ip_address?(value)
116
+ IPAddr.new(value)
117
+ true
118
+ rescue IPAddr::Error
119
+ false
120
+ end
121
+
122
+ def positive_integer?(value)
123
+ value.to_s.match?(/\A[1-9][0-9]*\z/)
124
+ end
125
+
126
+ def default_service_name(deploy_path)
127
+ parsed = YAML.safe_load(File.read(deploy_path), permitted_classes: [], aliases: false)
128
+ service = parsed.is_a?(Hash) ? parsed["service"] : nil
129
+ service || File.basename(destination_root)
130
+ rescue Psych::Exception
131
+ File.basename(destination_root)
132
+ end
133
+
134
+ def atomic_write(path, content)
135
+ dir = File.dirname(path)
136
+ mode = File.exist?(path) ? File.stat(path).mode & 0o777 : 0o644
137
+ tmp = Tempfile.new(".wide_events_deploy", dir)
138
+ tmp.write(content)
139
+ tmp.close
140
+ File.chmod(mode, tmp.path)
141
+ FileUtils.mv(tmp.path, path)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,33 @@
1
+ wide_events:
2
+ image: <%= image %>
3
+ host: <%= host %>
4
+
5
+ proxy:
6
+ host: <%= hostname %>
7
+ ssl: true
8
+ app_port: 7421
9
+ healthcheck:
10
+ path: /up
11
+ interval: 3
12
+ timeout: 3
13
+
14
+ env:
15
+ clear:
16
+ WIDE_EVENTS_LISTEN: 0.0.0.0:7421
17
+ WIDE_EVENTS_DATABASE: /var/lib/wide-events/events.duckdb
18
+ WIDE_EVENTS_SERVICE: <%= service %>
19
+ WIDE_EVENTS_ENVIRONMENT: <%= environment %>
20
+ WIDE_EVENTS_RETENTION_DAYS: <%= retention_days %>
21
+ secret:
22
+ - WIDE_EVENTS_INGEST_TOKEN
23
+ - WIDE_EVENTS_QUERY_TOKEN
24
+
25
+ directories:
26
+ - local: <%= local_directory %>
27
+ remote: /var/lib/wide-events
28
+ mode: "0750"
29
+ owner: "1000:1000"
30
+
31
+ options:
32
+ cpus: 2
33
+ memory: 2g
@@ -17,13 +17,26 @@ module WideEvent
17
17
  end
18
18
 
19
19
  attr_accessor :enabled, :max_cache_attrs, :span_scopes, :strict,
20
- :logger, :error_handler, :scheduled_job_detector
20
+ :logger, :error_handler, :scheduled_job_detector,
21
+ :store_url, :store_service, :store_environment,
22
+ :store_ingest_token, :store_query_token
21
23
  attr_reader :sink, :registry_path
22
24
 
25
+ # A resolved store sink is unusable until its configuration
26
+ # (url/service/environment/ingest token) is valid; falls back to this
27
+ # silent no-op so a misconfigured store never raises into a flush.
28
+ NOOP_STORE_SINK = Object.new.tap { |sink| sink.define_singleton_method(:flush) { |_attrs| nil } }.freeze
29
+
23
30
  def initialize
24
- @enabled = !ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].to_s.empty?
25
- @sink = :otel
31
+ @store_url = ENV["WIDE_EVENTS_URL"]
32
+ @enabled = @store_url.present? || ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present?
33
+ @sink = @store_url.present? ? :store : :otel
26
34
  @resolved_sink = nil
35
+ @store_service = ENV["WIDE_EVENTS_SERVICE"]
36
+ @store_environment = ENV["WIDE_EVENTS_ENVIRONMENT"]
37
+ @store_ingest_token = ENV["WIDE_EVENTS_INGEST_TOKEN"]
38
+ @store_query_token = ENV["WIDE_EVENTS_QUERY_TOKEN"]
39
+ @store_sender = nil
27
40
  @max_cache_attrs = 10
28
41
  @span_scopes = {
29
42
  "OpenTelemetry::Instrumentation::PG" => "postgres_query",
@@ -46,10 +59,18 @@ module WideEvent
46
59
  @resolved_sink ||= case @sink
47
60
  when :otel then Sinks::OtelSpan.new
48
61
  when :log then Sinks::LogLine.new(logger)
62
+ when :store then build_store_sink
49
63
  else @sink
50
64
  end
51
65
  end
52
66
 
67
+ # True once #resolved_sink has actually built (or attempted to build) a
68
+ # sink; lets WideEvent.shutdown! avoid constructing one from scratch
69
+ # just to shut it down.
70
+ def resolved?
71
+ !@resolved_sink.nil?
72
+ end
73
+
53
74
  def registry_path=(path)
54
75
  @registry_path = path
55
76
  @registry = nil
@@ -60,8 +81,48 @@ module WideEvent
60
81
  @registry ||= Registry.load(resolved_registry_path)
61
82
  end
62
83
 
84
+ # Stops the store sender's worker thread (if one was ever built) so
85
+ # tests and process shutdown never leak the background thread.
86
+ def shutdown_store_sender!(timeout: WideEvent::Store::Sender::SHUTDOWN_TIMEOUT)
87
+ @store_sender&.shutdown(timeout: timeout)
88
+ @store_sender = nil
89
+ end
90
+
63
91
  private
64
92
 
93
+ def build_store_sink
94
+ # A prior resolution (e.g. `config.sink = :store` twice, or
95
+ # re-assigning store_* fields and re-resolving) may have already
96
+ # built and started a sender; shut it down before replacing it so
97
+ # its worker thread and queue don't strand themselves, ownerless and
98
+ # retrying forever.
99
+ shutdown_store_sender!
100
+
101
+ raise ArgumentError, "WIDE_EVENTS_SERVICE is required for the store sink" if @store_service.to_s.strip.empty?
102
+ raise ArgumentError, "WIDE_EVENTS_ENVIRONMENT is required for the store sink" if @store_environment.to_s.strip.empty?
103
+ # Client itself no longer validates token presence at construction
104
+ # (a query-only Client has no ingest token to give it) — the ingest
105
+ # path validates this here, at sink-resolution time, instead.
106
+ raise ArgumentError, "WIDE_EVENTS_INGEST_TOKEN is required for the store sink" if @store_ingest_token.to_s.strip.empty?
107
+
108
+ client = WideEvent::Store::Client.new(url: @store_url, ingest_token: @store_ingest_token)
109
+ @store_sender = WideEvent::Store::Sender.new(client: client, service: @store_service, environment: @store_environment)
110
+ Sinks::Store.new(sender: @store_sender)
111
+ rescue StandardError => e
112
+ # This instance's own handler, not the module-level WideEvent.handle_error
113
+ # funnel: build_store_sink runs on whatever Configuration instance is
114
+ # resolving (in production that's always WideEvent.config, but tests
115
+ # also resolve standalone instances directly).
116
+ report_configuration_error(e)
117
+ NOOP_STORE_SINK
118
+ end
119
+
120
+ def report_configuration_error(exception)
121
+ error_handler&.call(exception, "store_sink_configuration")
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
65
126
  # `registry_path` stays relative so the generated doc can commit it;
66
127
  # absolute paths win inside Pathname#join and pass through as-is.
67
128
  def resolved_registry_path