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.
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 raisesNotImplementedError). - Requires
activesupportfor.camelcaseon unit strings. - Has not been updated since Feb 2023.
Our adapter avoids all of these by routing through Reporter.
lib/speedshop/cloudwatch/yabeda.rb # adapter class (~60 LOC)
test/test_yabeda.rb # tests
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.
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
METRICSandConfig#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 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!
Speedshop::Cloudwatch::Yabeda < ::Yabeda::BaseAdapter
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.
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_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",
}.freezeAnything not in the map → "None".
None. Namespace is derived from the Yabeda group name (titlecased). Unit is derived from the Yabeda metric's unit via the unit map.
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)
endThe file itself should guard on defined?(::Yabeda) or require "yabeda" and
raise a clear error if yabeda is not available.
Add yabeda as an optional development dependency (for testing). It is not
a runtime dependency.
spec.add_development_dependency "yabeda"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
SpeedshopCloudwatchTestto get theTestClient, config, and teardown (Reporter/Config reset) for free. - In setup:
require "yabeda", define Yabeda metrics viaYabeda.configure(a counter, gauge, histogram, and summary in a test group), instantiate the adapter, andregister_adapter(:cloudwatch, adapter). CallYabeda.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!thenreporter.flush_now!to synchronously push through the queue. - Assert against
@test_client— verify each metric landed input_metric_datawith the correct metric_name, value, dimensions, unit, and namespace.
- All four metric types flow end-to-end: increment/set/measure/observe → Reporter queue → flush → TestClient receives correct data.
- Tags become dimensions: Yabeda tags appear as CloudWatch dimension name/value pairs.
- Custom dimensions appended: Config
dimensionsare merged with tag-derived dimensions. - Namespace resolution: Group name is titlecased (
:sidekiq→"Sidekiq"). - Unit mapping: Known Yabeda units (
:seconds,:bytes, etc.) map to CloudWatch unit strings; unknown units default to"None". - Aggregation: Multiple increments within one flush interval are aggregated into a StatisticSet (verifies the Reporter path, not just the adapter).
- Disabled environment: Metrics are silently dropped when environment is
not in
enabled_environments.
This is a pure refactor — no new functionality, all existing tests must pass.
- Create
lib/speedshop/cloudwatch/metric_mapper.rbwith theMetricMapperclass. Movereport,metric_allowed?,find_integration_for_metric, andcustom_dimensionsout of Reporter and into MetricMapper. - Add
enqueue(datum)to Reporter as the sole public entry point for metrics. This is the queue-push + overflow +start!logic that currently lives inreport, minus the filtering/mapping. - Update all callers (Rack, Puma, Sidekiq, ActiveJob) to call MetricMapper instead of Reporter directly.
- Existing tests pass with no changes (or minimal call-target adjustments in test setup).
- Create
lib/speedshop/cloudwatch/yabeda.rbwith the adapter class. - Add
yabedadev dependency to gemspec. - Write tests in
test/yabeda_test.rb. - Add usage docs to README.