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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +1 -0
- data/lib/fast/prometheus/instrumentation/completions.rb +200 -0
- data/lib/fast/prometheus/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4aaf85a067f816ee90dbc1fd3377a92945e537ea95f28488ca54ea6d91b5f29f
|
|
4
|
+
data.tar.gz: 456e78aadc75e798193304f29dff4ec06ed5eb228a38c58be81a949348e640cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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
|
+
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
|