fast-prometheus 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0421a8eb55a0265588296f0cdac33f1028cf6785c629a85f192f354bcec7d55a
4
- data.tar.gz: bd02dcbb69a4a0f5bd5f592f36b9e984a3fc3cd74f2023fcb95c21f0d9662f10
3
+ metadata.gz: 4aaf85a067f816ee90dbc1fd3377a92945e537ea95f28488ca54ea6d91b5f29f
4
+ data.tar.gz: 456e78aadc75e798193304f29dff4ec06ed5eb228a38c58be81a949348e640cf
5
5
  SHA512:
6
- metadata.gz: bfb624426fc812ea430544cdc0f9d1690a2cde91d65949e77765730bde6ebc264cbb1e8c774c7faad6cb1da8202543f36f26e4bc987c7836aea1ec506bcc0763
7
- data.tar.gz: 30dfdebab4860d83532092fa969298f8b38d79b18573980baaca8430b9a910e3aad4e541b6a71da0e41cca8a291d41ab0c28115e10abbaef0adc35caeca5241e
6
+ metadata.gz: 1aa2448506788dc991aa7c0761c59122bb73a11a3929217aedad30e2bdb29f8129a1022ad5b6e8a74c43ad17524f4353b18cc374ae3ae70f7bc366d8ab97e581
7
+ data.tar.gz: edd4e3a049e6d6caafc80b1a3b77f54c074eb777b40c075acdb704707558669560c02356857c6ddc33d535096a3a67d125ec1c07d9001fc3a45cb3d90199daf1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-19
11
+
12
+ ### Added
13
+
14
+ - `fast/prometheus/instrumentation/completions` — an opt-in telemetry
15
+ adapter for the [fast-completions](https://github.com/jetpks/fast-llm)
16
+ gem: `Fast::Prometheus::Instrumentation::Completions` includes
17
+ `Fast::Completions::Telemetry::Sink` and turns the completion hooks into
18
+ five metrics on a registry — `llm_completions_total`
19
+ (`provider`/`model`/`stop_reason`), `llm_completions_errors_total`
20
+ (`error`), `llm_completions_duration_seconds` and
21
+ `llm_completions_ttfb_seconds` (`provider`/`model` and unlabeled
22
+ respectively, default buckets), and `llm_completions_tokens_total`
23
+ (`provider`/`model`/`kind`). Labels come from the live assistant Message
24
+ (`provider`/`model`, nil collapsed to `"unknown"` — never
25
+ `response_model`/`response_id`), the hook Symbols (`stop_reason`,
26
+ `error`), and the frozen `kind` values `"input"`/`"output"`; a nil
27
+ `usage` skips the token counters. A warm notification-to-metric dispatch
28
+ allocates nothing: chained Hash lookups into metrics pre-bound once per
29
+ series via `with_labels`. fast-completions stays a runtime peer — the
30
+ require path fails loud with `LoadError` when the peer gem is absent —
31
+ and a metric pre-registered under one of the five names is validated
32
+ (type and labels) at construction, the `RequestMetrics` pattern. See
33
+ [the how-to](docs/how-to/instrument-completions.md).
34
+
10
35
  ## [0.4.0] - 2026-09-19
11
36
 
12
37
  ### Changed
data/README.md CHANGED
@@ -62,6 +62,7 @@ under Falcon and scraping it with a real Prometheus, see
62
62
  - [How to scrape native histograms](docs/how-to/scrape-native-histograms.md) — configure Prometheus to negotiate protobuf so native histograms aren't dropped.
63
63
  - [How to share one registry across boot paths](docs/how-to/share-a-registry.md) — use `fetch_or_register` so multiple boot paths can construct the same metric safely.
64
64
  - [How to export over OTLP](docs/how-to/export-otlp.md) — push metrics to an OTLP collector over gRPC or HTTP.
65
+ - [How to instrument LLM completions](docs/how-to/instrument-completions.md) — record the fast-completions telemetry metrics from a `Fast::Completions` client.
65
66
  - [How to verify a release build](docs/how-to/verify-a-release.md) — run the integration harness against a packaged gem before tagging.
66
67
 
67
68
  ### Reference
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast/prometheus"
4
+
5
+ # The documented soft require: fast-completions is a runtime peer, never a
6
+ # gemspec dependency of this gem, so an app without it fails loud here — at
7
+ # require time, where the missing peer belongs — instead of half-loading a
8
+ # sink that can never fire. See docs/how-to/instrument-completions.md.
9
+ require "fast/completions/telemetry"
10
+
11
+ module Fast
12
+ module Prometheus
13
+ module Instrumentation
14
+ # The fast-completions telemetry adapter: one sink object that turns
15
+ # the Telemetry::Sink observation points into five metric families on
16
+ # a registry. Construct it and hand it to Fast::Completions::Client:
17
+ #
18
+ # sink = Fast::Prometheus::Instrumentation::Completions.new
19
+ # client = Fast::Completions::Client.new(..., telemetry: [sink])
20
+ #
21
+ # Metric table (frozen):
22
+ # llm_completions_total Counter provider, model, stop_reason
23
+ # llm_completions_errors_total Counter error
24
+ # llm_completions_duration_seconds Histogram provider, model
25
+ # llm_completions_ttfb_seconds Histogram (none)
26
+ # llm_completions_tokens_total Counter provider, model, kind
27
+ #
28
+ # Label values come from the hook arguments and the LIVE assistant
29
+ # Message — provider is message.provider, model is message.model
30
+ # (never response_model/response_id, which are wire metadata, not
31
+ # series identity), stop_reason/error are the hook Symbols, kind is
32
+ # the frozen "input"/"output". The Message contract allows a nil
33
+ # provider or model; those become the "unknown" sentinel so one nil
34
+ # is one series, not a raise.
35
+ #
36
+ # A warm dispatch allocates NOTHING: every hook is chained Hash
37
+ # lookups over existing refs into a pre-bound metric — no label Hash,
38
+ # no Array key, no String is built. A cold series binds once through
39
+ # #with_labels and is cached under the raw hook values, so the miss
40
+ # cost is paid once per (provider, model, stop_reason/kind) triple.
41
+ class Completions
42
+ include Fast::Completions::Telemetry::Sink
43
+
44
+ END_LABELS = %i[provider model stop_reason].freeze
45
+ ERROR_LABELS = %i[error].freeze
46
+ DURATION_LABELS = %i[provider model].freeze
47
+ TOKEN_LABELS = %i[provider model kind].freeze
48
+
49
+ # nil provider/model collapse here (the Message contract allows
50
+ # both to be nil) rather than minting an exception series.
51
+ UNKNOWN = "unknown"
52
+
53
+ COUNTER_TYPES = %i[counter].freeze
54
+ HISTOGRAM_TYPES = %i[histogram].freeze
55
+
56
+ private_constant :END_LABELS, :ERROR_LABELS, :DURATION_LABELS, :TOKEN_LABELS,
57
+ :COUNTER_TYPES, :HISTOGRAM_TYPES
58
+
59
+ # +registry+ defaults to the module-level registry, so a second
60
+ # adapter constructed anywhere reuses the same five metrics
61
+ # (Registry#fetch_or_register) and shares their series.
62
+ def initialize(registry: Fast::Prometheus.registry)
63
+ @registry = registry
64
+
65
+ # Series caches, one level per label: provider → model →
66
+ # stop_reason/kind (or plain model for the duration histogram)
67
+ # → the bound metric itself. The ttfb histogram is unlabeled and
68
+ # dispatched directly. Keyed by the raw hook values, so a hit is
69
+ # Hash lookups with existing refs and nothing else.
70
+ @ends = {}
71
+ @durations = {}
72
+ @tokens = {}
73
+ @errors = {}
74
+
75
+ @completions_total = ensure_counter(:llm_completions_total, END_LABELS,
76
+ "Total LLM completions by stop reason")
77
+ @errors_total = ensure_counter(:llm_completions_errors_total, ERROR_LABELS,
78
+ "Total LLM completions that raised out of the client")
79
+ @duration_seconds = ensure_histogram(:llm_completions_duration_seconds, DURATION_LABELS,
80
+ "LLM completion duration in seconds")
81
+ @ttfb_seconds = ensure_histogram(:llm_completions_ttfb_seconds, [].freeze,
82
+ "LLM completion time to first byte in seconds")
83
+ @tokens_total = ensure_counter(:llm_completions_tokens_total, TOKEN_LABELS,
84
+ "LLM completion token usage by kind")
85
+ end
86
+
87
+ # on_completion_start and on_event stay the Sink's no-ops: the
88
+ # metric table is scoped to ttfb and the two terminal hooks — the
89
+ # per-event surface belongs to whoever reads the Message.
90
+
91
+ def on_completion_ttfb(seconds)
92
+ @ttfb_seconds.observe(seconds)
93
+ end
94
+
95
+ def on_completion_end(stop_reason, duration, message)
96
+ provider = message.provider
97
+ model = message.model
98
+
99
+ # Warm: chained lookups into existing bound metrics. Cold: the
100
+ # bind helpers below run once per series and cache the binding.
101
+ counter = ((@ends[provider] ||= {})[model] ||= {})[stop_reason]
102
+ counter ||= bind_end(provider, model, stop_reason)
103
+ counter.increment
104
+
105
+ histogram = (@durations[provider] ||= {})[model]
106
+ histogram ||= bind_duration(provider, model)
107
+ histogram.observe(duration)
108
+
109
+ # The Message contract allows nil usage (and the read-only
110
+ # convention says nil-check without allocating): a completion
111
+ # without accounting skips the token counters entirely.
112
+ usage = message.usage
113
+ return unless usage
114
+
115
+ kinds = (@tokens[provider] ||= {})[model]
116
+ kinds ||= bind_tokens(provider, model)
117
+ kinds[:input].increment(by: usage.input)
118
+ kinds[:output].increment(by: usage.output)
119
+ end
120
+
121
+ # Errored completions get the errors counter and no duration: the
122
+ # histogram observes only completions that returned.
123
+ def on_completion_error(error, _duration)
124
+ counter = @errors[error]
125
+ counter ||= bind_error(error)
126
+ counter.increment
127
+ end
128
+
129
+ private
130
+
131
+ def ensure_counter(name, labels, docstring)
132
+ metric = @registry.fetch_or_register(name) do
133
+ Counter.new(name, docstring: docstring, labels: labels)
134
+ end
135
+
136
+ validate(metric, COUNTER_TYPES, labels)
137
+ end
138
+
139
+ def ensure_histogram(name, labels, docstring)
140
+ metric = @registry.fetch_or_register(name) do
141
+ Histogram.new(name, docstring: docstring, labels: labels)
142
+ end
143
+
144
+ validate(metric, HISTOGRAM_TYPES, labels)
145
+ end
146
+
147
+ # A metric already registered under one of these names is reused
148
+ # only if it is the table's type and declares the table's labels.
149
+ # Otherwise every completion would raise from inside the client's
150
+ # dispatch; raise here instead, once, at construction, where the
151
+ # mistaken declaration is (the RequestMetrics pattern).
152
+ def validate(metric, types, labels)
153
+ unless types.include?(metric.type)
154
+ raise InvalidMetricType, "#{metric.name} declares type #{metric.type.inspect}, not #{types.inspect}"
155
+ end
156
+
157
+ unless metric.labels == labels
158
+ raise InvalidLabelSet, "#{metric.name} declares labels #{metric.labels.inspect}, not #{labels.inspect}"
159
+ end
160
+
161
+ metric
162
+ end
163
+
164
+ def bind_end(provider, model, stop_reason)
165
+ @ends[provider][model][stop_reason] = @completions_total.with_labels(
166
+ provider: label_value(provider), model: label_value(model), stop_reason: stop_reason
167
+ )
168
+ end
169
+
170
+ def bind_duration(provider, model)
171
+ @durations[provider][model] = @duration_seconds.with_labels(
172
+ provider: label_value(provider), model: label_value(model)
173
+ )
174
+ end
175
+
176
+ def bind_tokens(provider, model)
177
+ kinds = @tokens[provider][model] = {}
178
+ kinds[:input] = @tokens_total.with_labels(
179
+ provider: label_value(provider), model: label_value(model), kind: "input"
180
+ )
181
+ kinds[:output] = @tokens_total.with_labels(
182
+ provider: label_value(provider), model: label_value(model), kind: "output"
183
+ )
184
+ kinds
185
+ end
186
+
187
+ def bind_error(error)
188
+ @errors[error] = @errors_total.with_labels(error: error)
189
+ end
190
+
191
+ # with_labels normalizes label values to frozen UTF-8 Strings, so
192
+ # passing the hook Symbol binds its to_s and passing a String
193
+ # binds the ref — nothing here runs on a warm dispatch.
194
+ def label_value(value)
195
+ value || UNKNOWN
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Prometheus
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-prometheus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -109,6 +109,7 @@ files:
109
109
  - lib/fast/prometheus/formats/text.rb
110
110
  - lib/fast/prometheus/gauge.rb
111
111
  - lib/fast/prometheus/histogram.rb
112
+ - lib/fast/prometheus/instrumentation/completions.rb
112
113
  - lib/fast/prometheus/metric.rb
113
114
  - lib/fast/prometheus/middleware/exporter.rb
114
115
  - lib/fast/prometheus/middleware/instrumentation.rb