Skip to content

Instantly share code, notes, and snippets.

@nateberkopec
Created March 15, 2026 22:20
Show Gist options
  • Select an option

  • Save nateberkopec/2a240e9518b3def06c02050254b499fa to your computer and use it in GitHub Desktop.

Select an option

Save nateberkopec/2a240e9518b3def06c02050254b499fa to your computer and use it in GitHub Desktop.
Plan: Yabeda monitoring system adapter for speedshop-cloudwatch

Plan: Yabeda Monitoring System Adapter

Goal

Add an optional Yabeda adapter so that users who define metrics via Yabeda's DSL can have those metrics sent to CloudWatch through the existing Reporter infrastructure (async batching, StatisticSet aggregation, queue overflow protection, high-resolution support).

This gem does not collect metrics via Yabeda. The existing Puma/Sidekiq/Rack/ActiveJob collectors remain unchanged. This adapter is purely a "monitoring system backend" that Yabeda dispatches to when application code calls .increment, .set, or .measure on Yabeda-defined metrics.

Why not retsef/yabeda-cloudwatch?

That gem makes a synchronous put_metric_data call on every single metric update — no batching, no aggregation, no queue. It also:

  • Treats counter increments as absolute values (lossy).
  • Has no summary support (register_summary! / perform_summary_observe! are missing, so calling them raises NotImplementedError).
  • Requires activesupport for .camelcase on unit strings.
  • Has not been updated since Feb 2023.

Our adapter avoids all of these by routing through Reporter.

Design

New files

lib/speedshop/cloudwatch/yabeda.rb       # adapter class (~60 LOC)
test/test_yabeda.rb                      # tests

Architecture (Gateway + Data Mapper)

Reporter is refactored into a pure Gateway (Fowler) — it encapsulates access to CloudWatch. It accepts datum hashes (the Data Transfer Object), queues them, aggregates, batches, and flushes. No knowledge of metric definitions, integrations, or allowlists.

Each metric source gets its own Data Mapper that translates from its domain into the datum DTO and calls the Gateway.

Built-in collectors (Puma, Sidekiq, Rack, ActiveJob)
  → MetricMapper.report(metric:, value:, integration:, ...)
      [allowlist check, METRICS lookup, namespace/unit resolution, datum construction]
      → Reporter.enqueue(datum)

Yabeda (via BaseAdapter callbacks)
  → Speedshop::Cloudwatch::Yabeda#perform_*
      [unit mapping, namespace resolution, datum construction]
      → Reporter.enqueue(datum)

                    Reporter (Gateway)
                    [queue → aggregate → batch → put_metric_data]

Both mappers produce the same DTO shape. Reporter doesn't care where it came from. Adding a third metric source later means writing another mapper — no changes to Reporter or the existing mappers.

MetricMapper (new class, extracted from Reporter)

Speedshop::Cloudwatch::MetricMapper takes over the responsibilities currently in Reporter#report:

  • Allowlist check (config.metrics[integration].include?(metric_name))
  • Integration detection (find_integration_for_metric)
  • Unit/namespace lookup from METRICS and Config#namespaces
  • Custom dimensions merging
  • Datum hash construction
  • Calling Reporter.instance.enqueue(datum)

All existing callers (Rack, Puma, Sidekiq, ActiveJob) change their call target from Reporter.instance.report(...) to MetricMapper.instance.report(...) (or similar). The report(...) signature stays the same.

Reporter (Gateway, simplified)

Reporter drops report, metric_allowed?, find_integration_for_metric, and the METRICS lookup. Its public enqueue interface becomes:

enqueue(datum)   # datum = {metric_name:, namespace:, unit:, dimensions:, value:/statistic_values:, timestamp:}
start!
stop!
flush_now!

Yabeda adapter (Data Mapper for Yabeda metrics)

Speedshop::Cloudwatch::Yabeda < ::Yabeda::BaseAdapter

Yabeda BaseAdapter interface

Yabeda::BaseAdapter defines the contract. All methods raise NotImplementedError by default; subclasses must override them. The two categories:

Registration (called once per metric during Yabeda.configure!):

  • register_counter!(metric)
  • register_gauge!(metric)
  • register_histogram!(metric)
  • register_summary!(metric)

All four are no-ops for CloudWatch (no pre-registration needed).

Perform (called on every metric update at runtime):

  • perform_counter_increment!(counter, tags, increment)
  • perform_gauge_set!(gauge, tags, value)
  • perform_histogram_measure!(histogram, tags, value)
  • perform_summary_observe!(summary, tags, value)

The metric argument in all methods is a Yabeda metric object with .name, .group, .unit, .comment, and .tags accessors. tags is a Hash of tag-name → tag-value. The third argument is always the numeric value.

CloudWatch has no native summary type, so summaries are handled identically to histograms — values naturally aggregate into StatisticSets in the Reporter.

Namespace resolution

Titlecase the Yabeda group name: metric.group.to_s.capitalize"sidekiq" becomes "Sidekiq", "rails" becomes "Rails". This matches the existing built-in namespace casing, so migrating from built-in collectors to Yabeda plugins produces the same CloudWatch namespaces with zero config.

Unit mapping

UNIT_MAP = {
  seconds:      "Seconds",
  milliseconds: "Milliseconds",
  microseconds: "Microseconds",
  bytes:        "Bytes",
  kilobytes:    "Kilobytes",
  megabytes:    "Megabytes",
  gigabytes:    "Gigabytes",
  terabytes:    "Terabytes",
  bits:         "Bits",
  kilobits:     "Kilobits",
  megabits:     "Megabits",
  gigabits:     "Gigabits",
  terabits:     "Terabits",
  percent:      "Percent",
  count:        "Count",
}.freeze

Anything not in the map → "None".

Configuration additions

None. Namespace is derived from the Yabeda group name (titlecased). Unit is derived from the Yabeda metric's unit via the unit map.

Loading / opt-in

The adapter file is not loaded by all.rb. Users opt in:

# In an initializer, after Yabeda.configure
require "speedshop/cloudwatch/yabeda"

Yabeda.configure do
  adapter = Speedshop::Cloudwatch::Yabeda.new
  register_adapter(:cloudwatch, adapter)
end

The file itself should guard on defined?(::Yabeda) or require "yabeda" and raise a clear error if yabeda is not available.

Gemspec

Add yabeda as an optional development dependency (for testing). It is not a runtime dependency.

spec.add_development_dependency "yabeda"

Testing

Full smoke test in test/yabeda_test.rb, following the same pattern as the existing integration tests (active_job_test.rb, puma_test.rb, etc.):

  • Subclass SpeedshopCloudwatchTest to get the TestClient, config, and teardown (Reporter/Config reset) for free.
  • In setup: require "yabeda", define Yabeda metrics via Yabeda.configure (a counter, gauge, histogram, and summary in a test group), instantiate the adapter, and register_adapter(:cloudwatch, adapter). Call Yabeda.configure!.
  • Exercise Yabeda's public API directly:
    • Yabeda.test_group.my_counter.increment({tag: "v"}, by: 5)
    • Yabeda.test_group.my_gauge.set({tag: "v"}, 42)
    • Yabeda.test_group.my_histogram.measure({tag: "v"}, 1.5)
    • Yabeda.test_group.my_summary.observe({tag: "v"}, 2.0)
  • Call reporter.start! then reporter.flush_now! to synchronously push through the queue.
  • Assert against @test_client — verify each metric landed in put_metric_data with the correct metric_name, value, dimensions, unit, and namespace.

Specific test cases

  1. All four metric types flow end-to-end: increment/set/measure/observe → Reporter queue → flush → TestClient receives correct data.
  2. Tags become dimensions: Yabeda tags appear as CloudWatch dimension name/value pairs.
  3. Custom dimensions appended: Config dimensions are merged with tag-derived dimensions.
  4. Namespace resolution: Group name is titlecased (:sidekiq"Sidekiq").
  5. Unit mapping: Known Yabeda units (:seconds, :bytes, etc.) map to CloudWatch unit strings; unknown units default to "None".
  6. Aggregation: Multiple increments within one flush interval are aggregated into a StatisticSet (verifies the Reporter path, not just the adapter).
  7. Disabled environment: Metrics are silently dropped when environment is not in enabled_environments.

Steps

Step 1: Extract MetricMapper from Reporter (separate commit)

This is a pure refactor — no new functionality, all existing tests must pass.

  1. Create lib/speedshop/cloudwatch/metric_mapper.rb with the MetricMapper class. Move report, metric_allowed?, find_integration_for_metric, and custom_dimensions out of Reporter and into MetricMapper.
  2. Add enqueue(datum) to Reporter as the sole public entry point for metrics. This is the queue-push + overflow + start! logic that currently lives in report, minus the filtering/mapping.
  3. Update all callers (Rack, Puma, Sidekiq, ActiveJob) to call MetricMapper instead of Reporter directly.
  4. Existing tests pass with no changes (or minimal call-target adjustments in test setup).

Step 2: Add Yabeda adapter

  1. Create lib/speedshop/cloudwatch/yabeda.rb with the adapter class.
  2. Add yabeda dev dependency to gemspec.
  3. Write tests in test/yabeda_test.rb.
  4. Add usage docs to README.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment