ask-observability 0.1.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 +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/lib/ask/observability/bootstrap.rb +87 -0
- data/lib/ask/observability/configuration.rb +55 -0
- data/lib/ask/observability/metrics.rb +82 -0
- data/lib/ask/observability/metrics_app.rb +18 -0
- data/lib/ask/observability/railtie.rb +42 -0
- data/lib/ask/observability/subscriber.rb +80 -0
- data/lib/ask/observability/version.rb +7 -0
- data/lib/ask/observability.rb +118 -0
- data/lib/ask-observability.rb +3 -0
- data/lib/generators/ask/observability/install/templates/initializer.rb.erb +29 -0
- data/lib/generators/ask/observability/install_generator.rb +26 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d0e28b911f472b83c6a7c93bcd632b9fe981fb39aa6a73e43dbeadc287199faa
|
|
4
|
+
data.tar.gz: 8480c74048d04b7e9cf83bfe9614d5d927bdb31865f11a5e159db2ba3ccbce26
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 49f733327ed7c5c5f3c53931919c41bb7234ec2b487f0dae8d85814cfa3eb3416009cc53e2deba9901d6bb00f1991db5e1ececfed726fe02c274628c031ee65e
|
|
7
|
+
data.tar.gz: ac2fa10d43298f99c045c3136bc9c524b1f50f1c9251378907d9df69c9b8f4ee9144284faa5afa3bd6e041784f7253bdaba796232f751224901f5c28d6931baa
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-08-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Subscriber turning `ask-instrumentation` events into Prometheus metrics:
|
|
15
|
+
`ask_llm_calls_total`, `ask_llm_tokens_total` (input/output),
|
|
16
|
+
`ask_llm_duration_seconds` histogram, `ask_llm_errors_total`.
|
|
17
|
+
- `Ask::Observability.install` (idempotent) and `uninstall` (test isolation).
|
|
18
|
+
- `Ask::Observability.registry` — process registry, injectable for tests.
|
|
19
|
+
- `Ask::Observability.with_context` — metadata into events AND Rails log tags.
|
|
20
|
+
- `Ask::Observability::Bootstrap` — OTel SDK + OTLP exporter wiring and JSON
|
|
21
|
+
structured logging via `rails_semantic_logger`, gated by `OTEL_DISABLED`
|
|
22
|
+
and the test environment.
|
|
23
|
+
- `Ask::Observability::MetricsApp` — Rack endpoint serving the registry in
|
|
24
|
+
Prometheus text format.
|
|
25
|
+
- Rails railtie — auto bootstrap, auto install, `/metrics` mount; all
|
|
26
|
+
honoring `Configuration#enabled`.
|
|
27
|
+
- `rails generate ask:observability:install` initializer generator.
|
|
28
|
+
- Configuration: `enabled`, `service_name`, `otlp_endpoint`, `json_logging`,
|
|
29
|
+
`metrics_path`, `label_metadata` (with cardinality guardrails).
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# ask-observability
|
|
2
|
+
|
|
3
|
+
Prometheus metrics, OpenTelemetry bootstrap, and structured logging for the
|
|
4
|
+
ask-rb ecosystem.
|
|
5
|
+
|
|
6
|
+
The ask-rb observability story composes four small gems:
|
|
7
|
+
|
|
8
|
+
| Gem | Job |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `ask-instrumentation` | emits an event for every LLM operation |
|
|
11
|
+
| `ask-opentelemetry` | events → OpenTelemetry **spans** |
|
|
12
|
+
| **ask-observability** | events → Prometheus **metrics**, plus the OTel + logging bootstrap and `/metrics` |
|
|
13
|
+
| `ask-monitoring` | events → in-app **dashboard** + alerts |
|
|
14
|
+
|
|
15
|
+
`ask-observability` is the infra-observability twin of `ask-monitoring`:
|
|
16
|
+
where the dashboard answers "what is the app spending?" inside the product,
|
|
17
|
+
these metrics answer "is the service healthy?" in Prometheus/OpenObserve/
|
|
18
|
+
Grafana, with alerting.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "ask-observability"
|
|
24
|
+
gem "ask-instrumentation"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start (plain Ruby)
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "ask/observability"
|
|
31
|
+
|
|
32
|
+
Ask::Observability.install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Every `Ask::Instrumentation` event now maintains:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
ask_llm_calls_total{provider,model,kind}
|
|
39
|
+
ask_llm_tokens_total{provider,model,kind,direction}
|
|
40
|
+
ask_llm_duration_seconds{provider,model,kind}
|
|
41
|
+
ask_llm_errors_total{provider,kind}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Read the registry from anywhere:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
Ask::Observability.registry
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start (Rails)
|
|
51
|
+
|
|
52
|
+
The railtie auto-installs: OpenTelemetry spans export to the OTLP collector,
|
|
53
|
+
logs become JSON on stdout, `/metrics` is mounted, and the subscriber runs.
|
|
54
|
+
Run the generator for a config file to tune it:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
rails generate ask:observability:install
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Ask::Observability.configure do |config|
|
|
62
|
+
config.service_name = "my-app"
|
|
63
|
+
config.label_metadata = [:workspace_id] # low-cardinality ONLY
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Configuration
|
|
68
|
+
|
|
69
|
+
| Option | Default | Purpose |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `enabled` | `true` | master switch (set `false` in tests/CI) |
|
|
72
|
+
| `service_name` | Rails app name / `"ask-app"` | `service.name` for spans and logs |
|
|
73
|
+
| `otlp_endpoint` | `ENV["OTEL_EXPORTER_OTLP_ENDPOINT"]` or `http://localhost:4318/v1/traces` | OTLP/HTTP span export |
|
|
74
|
+
| `json_logging` | `true` | JSON logs on stdout via `rails_semantic_logger` (skipped if the app configured its own appenders) |
|
|
75
|
+
| `metrics_path` | `"/metrics"` | where `/metrics` is mounted (`nil` disables the mount) |
|
|
76
|
+
| `label_metadata` | `[]` | `Ask::Instrumentation.with_metadata` keys exposed as metric labels |
|
|
77
|
+
|
|
78
|
+
> **Cardinality warning:** every distinct label value creates a new time
|
|
79
|
+
> series. Only configure low-cardinality metadata keys (`workspace_id`,
|
|
80
|
+
> `org`, `tenant`). Never put request ids or call ids in `label_metadata`.
|
|
81
|
+
> Configure before the first event — prometheus-client fixes a metric's
|
|
82
|
+
> label keys at creation.
|
|
83
|
+
|
|
84
|
+
### Correlating a unit of work everywhere
|
|
85
|
+
|
|
86
|
+
`with_context` pushes the same metadata into instrumentation events (and
|
|
87
|
+
thus spans + metrics) *and* into Rails log lines as tags:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Ask::Observability.with_context(call_id: "abc", workspace_id: 4) do
|
|
91
|
+
# events carry call_id/workspace_id; Rails logs are tagged with them
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Serving metrics yourself
|
|
96
|
+
|
|
97
|
+
If the mounted path conflicts with your app, point `metrics_path` elsewhere
|
|
98
|
+
or `nil` and mount the Rack app yourself:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# config/routes.rb
|
|
102
|
+
mount Ask::Observability::MetricsApp, at: "/ops/metrics"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Disabling
|
|
106
|
+
|
|
107
|
+
Set `config.enabled = false` — the subscriber, the OTel bootstrap, and the
|
|
108
|
+
/metrics mount all no-op. For tests, `OTEL_DISABLED=true` (or the test
|
|
109
|
+
environment) additionally skips the OpenTelemetry SDK configuration.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
bin/setup # bundle install + git hooks
|
|
115
|
+
bundle exec rake test
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/string/inflections'
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Observability
|
|
7
|
+
# One-call runtime setup for a Rails app, mirroring what every
|
|
8
|
+
# OpenTelemetry + structured-logging app hand-rolls:
|
|
9
|
+
#
|
|
10
|
+
# * OpenTelemetry SDK wired to the OTLP collector (spans out),
|
|
11
|
+
# gated by OTEL_DISABLED and the test environment;
|
|
12
|
+
# * JSON structured logs on stdout via rails_semantic_logger (when
|
|
13
|
+
# that gem is present and the app hasn't configured appenders).
|
|
14
|
+
#
|
|
15
|
+
# The railtie calls +install+ automatically; everything can be disabled
|
|
16
|
+
# through Configuration.
|
|
17
|
+
module Bootstrap
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Installs both halves. Safe to call multiple times.
|
|
21
|
+
def install
|
|
22
|
+
install_otel
|
|
23
|
+
install_json_logging
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Configure the global OpenTelemetry SDK once: batch span processor
|
|
27
|
+
# exporting OTLP/HTTP to Configuration#otlp_endpoint, service name
|
|
28
|
+
# from Configuration (or the Rails app name). The host app decides
|
|
29
|
+
# which instrumentations to bundle (rack, rails, active_job, ...);
|
|
30
|
+
# +use_all+ picks up whichever are installed.
|
|
31
|
+
def install_otel
|
|
32
|
+
return if @otel_installed
|
|
33
|
+
return if ENV['OTEL_DISABLED'] == 'true'
|
|
34
|
+
# ::Rails — inside Ask::*, the bare constant resolves to the sibling
|
|
35
|
+
# Ask::Rails module when ask-rails is loaded.
|
|
36
|
+
return if defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?
|
|
37
|
+
|
|
38
|
+
require 'opentelemetry-sdk'
|
|
39
|
+
require 'opentelemetry-exporter-otlp'
|
|
40
|
+
|
|
41
|
+
# Absolute ::OpenTelemetry: from inside Ask::*, the bare constant
|
|
42
|
+
# would resolve to Ask::OpenTelemetry (the sibling gem) first.
|
|
43
|
+
::OpenTelemetry::SDK.configure do |config|
|
|
44
|
+
config.service_name = service_name
|
|
45
|
+
config.add_span_processor(
|
|
46
|
+
::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
47
|
+
::OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: Ask::Observability.config.otlp_endpoint)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
config.use_all if defined?(::OpenTelemetry::Instrumentation::Rack)
|
|
51
|
+
end
|
|
52
|
+
@otel_installed = true
|
|
53
|
+
rescue LoadError, StandardError => e
|
|
54
|
+
warn "[ask-observability] OpenTelemetry bootstrap skipped: #{e.class}: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# JSON appender on stdout — one line per record, shippable to any
|
|
58
|
+
# log collector. Only applied when rails_semantic_logger is present
|
|
59
|
+
# and the host app hasn't declared its own appenders.
|
|
60
|
+
def install_json_logging
|
|
61
|
+
return unless Ask::Observability.config.json_logging
|
|
62
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
63
|
+
|
|
64
|
+
app = ::Rails.application
|
|
65
|
+
options = app.config.rails_semantic_logger
|
|
66
|
+
return unless options
|
|
67
|
+
return if options.respond_to?(:appenders?) && options.appenders?
|
|
68
|
+
|
|
69
|
+
app.config.rails_semantic_logger.appenders do |appenders|
|
|
70
|
+
appenders.add(io: $stdout, formatter: :json)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# service.name for spans: explicit config, else the Rails app name,
|
|
75
|
+
# else a generic fallback.
|
|
76
|
+
def service_name
|
|
77
|
+
Ask::Observability.config.service_name || begin
|
|
78
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
79
|
+
::Rails.application.class.module_parent_name.to_s.underscore
|
|
80
|
+
else
|
|
81
|
+
'ask-app'
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Observability
|
|
5
|
+
# Configuration for ask-observability. Set before the railtie runs
|
|
6
|
+
# (config/initializers/ask_observability.rb) so the metrics are created
|
|
7
|
+
# with their final label set — prometheus-client fixes a metric's label
|
|
8
|
+
# keys at creation time.
|
|
9
|
+
class Configuration
|
|
10
|
+
# Master switch: when false, the subscriber, the OpenTelemetry
|
|
11
|
+
# bootstrap, and the /metrics mount all no-op. Set in tests/CI to
|
|
12
|
+
# keep output clean.
|
|
13
|
+
attr_accessor :enabled
|
|
14
|
+
|
|
15
|
+
# service.name for OpenTelemetry spans and logs. Defaults to the
|
|
16
|
+
# Rails app name when running under Rails, else "ask-app".
|
|
17
|
+
attr_accessor :service_name
|
|
18
|
+
|
|
19
|
+
# OTLP/HTTP endpoint spans are exported to. Defaults to the local
|
|
20
|
+
# collector (OpenObserve reference stack).
|
|
21
|
+
attr_accessor :otlp_endpoint
|
|
22
|
+
|
|
23
|
+
# Structured JSON logs on stdout via rails_semantic_logger (applied
|
|
24
|
+
# when that gem is present and the app hasn't configured appenders).
|
|
25
|
+
attr_accessor :json_logging
|
|
26
|
+
|
|
27
|
+
# Mount path for the /metrics Rack endpoint; nil disables the mount
|
|
28
|
+
# (a host app that serves its own /metrics can point this elsewhere
|
|
29
|
+
# or nil and serve Ask::Observability.registry itself).
|
|
30
|
+
attr_accessor :metrics_path
|
|
31
|
+
|
|
32
|
+
# Metadata keys from Ask::Instrumentation.with_metadata to expose as
|
|
33
|
+
# metric labels. WARNING: every distinct value creates a new series —
|
|
34
|
+
# only use low-cardinality keys (workspace_id, org, tenant), never
|
|
35
|
+
# per-request ids.
|
|
36
|
+
attr_accessor :label_metadata
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@enabled = true
|
|
40
|
+
@service_name = nil
|
|
41
|
+
@otlp_endpoint = ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318/v1/traces')
|
|
42
|
+
@json_logging = true
|
|
43
|
+
@metrics_path = '/metrics'
|
|
44
|
+
@label_metadata = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The configured metadata keys, normalized to symbols.
|
|
48
|
+
#
|
|
49
|
+
# @return [Array<Symbol>]
|
|
50
|
+
def metadata_label_keys
|
|
51
|
+
@label_metadata.map(&:to_sym)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Observability
|
|
5
|
+
# Prometheus metric definitions for ask-instrumentation events.
|
|
6
|
+
#
|
|
7
|
+
# Created lazily on a registry so gems/apps that never emit events don't
|
|
8
|
+
# pay for registry entries, and so tests can inject a fresh registry.
|
|
9
|
+
# The label set is fixed at creation (prometheus-client requires stable
|
|
10
|
+
# label keys per metric) — hence the metadata keys must be configured
|
|
11
|
+
# before the metrics are first touched.
|
|
12
|
+
class Metrics
|
|
13
|
+
# Label keys every metric carries.
|
|
14
|
+
BASE_LABELS = %i[provider model kind].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Memoized per (registry, metadata keys) pair.
|
|
18
|
+
def for(registry, metadata_label_keys: [])
|
|
19
|
+
instances[[registry.object_id, metadata_label_keys]] ||=
|
|
20
|
+
new(registry, metadata_label_keys: metadata_label_keys)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def instances
|
|
24
|
+
@instances ||= {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# For tests: forget every Metrics instance (fresh registries).
|
|
28
|
+
def reset!
|
|
29
|
+
instances.clear
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(registry, metadata_label_keys: [])
|
|
34
|
+
@registry = registry
|
|
35
|
+
@metadata_label_keys = metadata_label_keys
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ask_llm_calls_total{provider,model,kind}
|
|
39
|
+
def calls_total
|
|
40
|
+
@calls_total ||= @registry.counter(
|
|
41
|
+
:ask_llm_calls_total,
|
|
42
|
+
docstring: 'LLM calls by provider, model, and kind',
|
|
43
|
+
labels: label_keys(:provider, :model, :kind)
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# ask_llm_tokens_total{provider,model,kind,direction}
|
|
48
|
+
def tokens_total
|
|
49
|
+
@tokens_total ||= @registry.counter(
|
|
50
|
+
:ask_llm_tokens_total,
|
|
51
|
+
docstring: 'LLM tokens by provider, model, kind, and direction',
|
|
52
|
+
labels: label_keys(:provider, :model, :kind, :direction)
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ask_llm_duration_seconds{provider,model,kind}
|
|
57
|
+
def duration_seconds
|
|
58
|
+
@duration_seconds ||= @registry.histogram(
|
|
59
|
+
:ask_llm_duration_seconds,
|
|
60
|
+
docstring: 'LLM call duration in seconds',
|
|
61
|
+
labels: label_keys(:provider, :model, :kind),
|
|
62
|
+
buckets: [0.1, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ask_llm_errors_total{provider,kind}
|
|
67
|
+
def errors_total
|
|
68
|
+
@errors_total ||= @registry.counter(
|
|
69
|
+
:ask_llm_errors_total,
|
|
70
|
+
docstring: 'LLM calls that errored',
|
|
71
|
+
labels: label_keys(:provider, :kind)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def label_keys(*keys)
|
|
78
|
+
(keys + @metadata_label_keys).uniq
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Observability
|
|
5
|
+
# Rack endpoint rendering the registry in Prometheus text format.
|
|
6
|
+
# Mounted by the railtie at Configuration#metrics_path; the collector
|
|
7
|
+
# scrapes it and ships to OpenObserve/Grafana.
|
|
8
|
+
class MetricsApp
|
|
9
|
+
def call(_env)
|
|
10
|
+
body = Prometheus::Client::Formats::Text.marshal(Ask::Observability.registry)
|
|
11
|
+
[200,
|
|
12
|
+
{ 'Content-Type' => 'text/plain; version=0.0.4; charset=utf-8',
|
|
13
|
+
'Content-Length' => body.bytesize.to_s },
|
|
14
|
+
[body]]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Observability
|
|
5
|
+
# Rails Railtie that wires ask-observability into any Rails app:
|
|
6
|
+
#
|
|
7
|
+
# * bootstraps OpenTelemetry + JSON logging,
|
|
8
|
+
# * installs the metrics subscriber,
|
|
9
|
+
# * mounts the /metrics endpoint (Configuration#metrics_path).
|
|
10
|
+
#
|
|
11
|
+
# Everything honors Configuration#enabled; a host app that wants none
|
|
12
|
+
# of it sets `enabled = false` in an initializer.
|
|
13
|
+
class Railtie < ::Rails::Railtie
|
|
14
|
+
# JSON logging must be declared before rails_semantic_logger builds
|
|
15
|
+
# the logger: its +:initialize_logger+ initializer (which replaces
|
|
16
|
+
# Rails') runs in group :all and consumes the appenders config there
|
|
17
|
+
# — anything added later lands on the default colored appender.
|
|
18
|
+
initializer 'ask.observability.logging', group: :all, before: :initialize_logger do |_app|
|
|
19
|
+
next if Ask::Observability.config.enabled == false
|
|
20
|
+
|
|
21
|
+
Ask::Observability::Bootstrap.install_json_logging
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
initializer 'ask.observability' do |_app|
|
|
25
|
+
next if Ask::Observability.config.enabled == false
|
|
26
|
+
|
|
27
|
+
Ask::Observability::Bootstrap.install
|
|
28
|
+
Ask::Observability.install
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
initializer 'ask.observability.metrics' do |app|
|
|
32
|
+
path = Ask::Observability.config.metrics_path
|
|
33
|
+
next if path.nil? || Ask::Observability.config.enabled == false
|
|
34
|
+
|
|
35
|
+
app.routes.append do
|
|
36
|
+
# MetricsApp is a Rack app (instance #call), so mount an instance.
|
|
37
|
+
mount Ask::Observability::MetricsApp.new, at: path
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Observability
|
|
5
|
+
# Turns Ask::Instrumentation events into Prometheus metrics:
|
|
6
|
+
#
|
|
7
|
+
# ask_llm_calls_total{provider,model,kind}
|
|
8
|
+
# ask_llm_tokens_total{provider,model,kind,direction}
|
|
9
|
+
# ask_llm_duration_seconds{provider,model,kind}
|
|
10
|
+
# ask_llm_errors_total{provider,kind}
|
|
11
|
+
#
|
|
12
|
+
# Metadata attached via Ask::Instrumentation.with_metadata is merged
|
|
13
|
+
# into every event payload, so configured metadata keys (see
|
|
14
|
+
# Configuration#label_metadata) are read straight off the payload —
|
|
15
|
+
# missing values render as "unknown".
|
|
16
|
+
class Subscriber
|
|
17
|
+
# Event name → metric kind (mirrors ask-opentelemetry's span map).
|
|
18
|
+
KIND_BY_EVENT = {
|
|
19
|
+
'chat.ask' => 'chat',
|
|
20
|
+
'chat.stream.ask' => 'chat',
|
|
21
|
+
'tool.ask' => 'tool',
|
|
22
|
+
'embedding.ask' => 'embedding',
|
|
23
|
+
'image.ask' => 'image'
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Invoked by ActiveSupport::Notifications for each matching event.
|
|
27
|
+
#
|
|
28
|
+
# @param event [ActiveSupport::Notifications::Event]
|
|
29
|
+
def call(event)
|
|
30
|
+
kind = KIND_BY_EVENT[event.name]
|
|
31
|
+
return unless kind
|
|
32
|
+
|
|
33
|
+
payload = event.payload
|
|
34
|
+
metrics = Ask::Observability.metrics
|
|
35
|
+
labels = base_labels(payload, kind).merge(metadata_labels(payload))
|
|
36
|
+
|
|
37
|
+
metrics.calls_total.increment(labels: labels)
|
|
38
|
+
metrics.duration_seconds.observe(event.duration / 1000.0, labels: labels)
|
|
39
|
+
record_tokens(metrics, labels, payload)
|
|
40
|
+
record_error(metrics, labels, payload) if payload[:error]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def base_labels(payload, kind)
|
|
46
|
+
{
|
|
47
|
+
provider: stringify(payload[:provider]),
|
|
48
|
+
model: stringify(payload[:model]),
|
|
49
|
+
kind: kind
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_tokens(metrics, labels, payload)
|
|
54
|
+
input = payload[:input_tokens].to_i
|
|
55
|
+
output = payload[:output_tokens].to_i
|
|
56
|
+
return if input.zero? && output.zero?
|
|
57
|
+
|
|
58
|
+
metrics.tokens_total.increment(by: input, labels: labels.merge(direction: 'input')) if input.positive?
|
|
59
|
+
metrics.tokens_total.increment(by: output, labels: labels.merge(direction: 'output')) if output.positive?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def record_error(metrics, labels, _payload)
|
|
63
|
+
metrics.errors_total.increment(labels: labels.except(:model))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Configured metadata keys present in the payload, as label values.
|
|
67
|
+
def metadata_labels(payload)
|
|
68
|
+
Ask::Observability.config.metadata_label_keys.to_h do |key|
|
|
69
|
+
[key, stringify(payload[key])]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Prometheus label values must be strings; absent values become
|
|
74
|
+
# "unknown" so the series stays addressable.
|
|
75
|
+
def stringify(value)
|
|
76
|
+
value.to_s.empty? ? 'unknown' : value.to_s
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ask/instrumentation'
|
|
4
|
+
require 'prometheus/client'
|
|
5
|
+
require 'prometheus/client/formats/text'
|
|
6
|
+
|
|
7
|
+
require_relative 'observability/version'
|
|
8
|
+
|
|
9
|
+
# Rails discovers gem railties only when they are required, so load the
|
|
10
|
+
# railtie from the entry file once Rails itself is on the stack (the
|
|
11
|
+
# documented pattern: "require my_gem/railtie if defined?(Rails::Railtie)").
|
|
12
|
+
require_relative 'observability/railtie' if defined?(Rails::Railtie)
|
|
13
|
+
|
|
14
|
+
module Ask
|
|
15
|
+
# Ask::Observability — Prometheus metrics, OpenTelemetry bootstrap, and
|
|
16
|
+
# structured logging for the ask-rb ecosystem.
|
|
17
|
+
#
|
|
18
|
+
# The ask-rb observability story composes:
|
|
19
|
+
# * ask-instrumentation — emits events for every LLM operation
|
|
20
|
+
# * ask-opentelemetry — events → OpenTelemetry spans
|
|
21
|
+
# * ask-observability — events → Prometheus metrics, plus the
|
|
22
|
+
# OTel/logging bootstrap and /metrics
|
|
23
|
+
# * ask-monitoring — events → in-app dashboard + alerts
|
|
24
|
+
#
|
|
25
|
+
# == Usage
|
|
26
|
+
#
|
|
27
|
+
# Ask::Observability.install
|
|
28
|
+
#
|
|
29
|
+
# In a Rails app the railtie auto-installs (bootstrap + subscriber +
|
|
30
|
+
# /metrics); configure it first:
|
|
31
|
+
#
|
|
32
|
+
# Ask::Observability.configure do |config|
|
|
33
|
+
# config.service_name = "my-app"
|
|
34
|
+
# config.label_metadata = [:workspace_id]
|
|
35
|
+
# end
|
|
36
|
+
module Observability
|
|
37
|
+
autoload :Bootstrap, 'ask/observability/bootstrap'
|
|
38
|
+
autoload :Configuration, 'ask/observability/configuration'
|
|
39
|
+
autoload :Metrics, 'ask/observability/metrics'
|
|
40
|
+
autoload :MetricsApp, 'ask/observability/metrics_app'
|
|
41
|
+
autoload :Railtie, 'ask/observability/railtie'
|
|
42
|
+
autoload :Subscriber, 'ask/observability/subscriber'
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# The process-wide configuration. Configure before install (or
|
|
46
|
+
# before the railtie runs) so metrics carry the final label set.
|
|
47
|
+
#
|
|
48
|
+
# @return [Configuration]
|
|
49
|
+
def config
|
|
50
|
+
@config ||= Configuration.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @yield [Configuration]
|
|
54
|
+
def configure
|
|
55
|
+
yield config
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The Prometheus registry backing every metric (and the /metrics
|
|
59
|
+
# endpoint). Tests inject a fresh registry to isolate state.
|
|
60
|
+
#
|
|
61
|
+
# @return [Prometheus::Client::Registry]
|
|
62
|
+
def registry
|
|
63
|
+
@registry ||= Prometheus::Client::Registry.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_writer :registry
|
|
67
|
+
|
|
68
|
+
# Subscribe to every ask-instrumentation event and start maintaining
|
|
69
|
+
# Prometheus metrics. Idempotent — subsequent calls are no-ops.
|
|
70
|
+
def install
|
|
71
|
+
return if installed?
|
|
72
|
+
return if config.enabled == false
|
|
73
|
+
|
|
74
|
+
@subscriber = Ask::Instrumentation.subscribe(/\.ask$/, Subscriber.new)
|
|
75
|
+
@installed = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Remove the subscriber (tests use this to keep suites isolated).
|
|
79
|
+
def uninstall
|
|
80
|
+
return unless @subscriber
|
|
81
|
+
|
|
82
|
+
Ask::Instrumentation.unsubscribe(@subscriber)
|
|
83
|
+
@subscriber = nil
|
|
84
|
+
@installed = false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Boolean] whether the subscriber is installed
|
|
88
|
+
def installed?
|
|
89
|
+
!!@installed
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Wrap a block so the given metadata flows into ask-instrumentation
|
|
93
|
+
# events (and thus into spans and metrics) AND into Rails log lines
|
|
94
|
+
# as tags, when Rails is present. One call to correlate a unit of
|
|
95
|
+
# work — a request, a job, a call — everywhere.
|
|
96
|
+
#
|
|
97
|
+
# @param metadata [Hash] e.g. {call_id: "abc", workspace_id: 4}
|
|
98
|
+
# @yield the block to run under the context
|
|
99
|
+
# @return [Object] the block's return value
|
|
100
|
+
def with_context(metadata, &block)
|
|
101
|
+
Ask::Instrumentation.with_metadata(metadata) do
|
|
102
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger.respond_to?(:tagged)
|
|
103
|
+
::Rails.logger.tagged(**metadata, &block)
|
|
104
|
+
else
|
|
105
|
+
yield
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The metric definitions for the current registry.
|
|
111
|
+
#
|
|
112
|
+
# @return [Metrics]
|
|
113
|
+
def metrics
|
|
114
|
+
Metrics.for(registry, metadata_label_keys: config.metadata_label_keys)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# ask-observability configuration.
|
|
2
|
+
#
|
|
3
|
+
# The railtie bootstraps OpenTelemetry (spans → OTLP collector), installs
|
|
4
|
+
# JSON structured logging, mounts /metrics, and subscribes to
|
|
5
|
+
# ask-instrumentation events to maintain ask_llm_* Prometheus metrics.
|
|
6
|
+
# Set the knobs below BEFORE boot completes so metrics get their final
|
|
7
|
+
# label set.
|
|
8
|
+
|
|
9
|
+
Ask::Observability.configure do |config|
|
|
10
|
+
# Master switch (useful in tests/CI).
|
|
11
|
+
# config.enabled = true
|
|
12
|
+
|
|
13
|
+
# service.name for spans and logs; defaults to the Rails app name.
|
|
14
|
+
# config.service_name = "my-app"
|
|
15
|
+
|
|
16
|
+
# OTLP/HTTP endpoint for spans (default: local OTel collector).
|
|
17
|
+
# config.otlp_endpoint = "http://localhost:4318/v1/traces"
|
|
18
|
+
|
|
19
|
+
# JSON logs on stdout via rails_semantic_logger.
|
|
20
|
+
# config.json_logging = true
|
|
21
|
+
|
|
22
|
+
# Where the /metrics endpoint is mounted (nil disables the mount).
|
|
23
|
+
# config.metrics_path = "/metrics"
|
|
24
|
+
|
|
25
|
+
# Metadata keys (from Ask::Instrumentation.with_metadata) to expose as
|
|
26
|
+
# metric labels. ONLY low-cardinality keys — every distinct value creates
|
|
27
|
+
# a new series. Never put request/call ids here.
|
|
28
|
+
# config.label_metadata = [:workspace_id]
|
|
29
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Observability
|
|
7
|
+
# Writes a config initializer so the app can tune ask-observability
|
|
8
|
+
# before the railtie runs (label metadata, service name, /metrics path).
|
|
9
|
+
#
|
|
10
|
+
# rails generate ask:observability:install
|
|
11
|
+
class InstallGenerator < Rails::Generators::Base
|
|
12
|
+
source_root File.expand_path('templates', __dir__)
|
|
13
|
+
|
|
14
|
+
def copy_initializer
|
|
15
|
+
template 'initializer.rb.erb', 'config/initializers/ask_observability.rb'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show_next_steps
|
|
19
|
+
say 'ask-observability installed. Configure it in ' \
|
|
20
|
+
'config/initializers/ask_observability.rb — the railtie ' \
|
|
21
|
+
'bootstraps OpenTelemetry + JSON logs, mounts /metrics, and ' \
|
|
22
|
+
'tracks ask_llm_* metrics automatically.'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-observability
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ask-instrumentation
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: prometheus-client
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '4.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '4.0'
|
|
40
|
+
description: Turns ask-instrumentation events into Prometheus metrics (calls, tokens,
|
|
41
|
+
duration, errors), bootstraps OpenTelemetry span export and JSON structured logging
|
|
42
|
+
for Rails apps, and serves a /metrics endpoint — the infra-observability twin of
|
|
43
|
+
ask-monitoring's in-app dashboard.
|
|
44
|
+
email:
|
|
45
|
+
- kaka@myrrlabs.com
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE
|
|
52
|
+
- README.md
|
|
53
|
+
- lib/ask-observability.rb
|
|
54
|
+
- lib/ask/observability.rb
|
|
55
|
+
- lib/ask/observability/bootstrap.rb
|
|
56
|
+
- lib/ask/observability/configuration.rb
|
|
57
|
+
- lib/ask/observability/metrics.rb
|
|
58
|
+
- lib/ask/observability/metrics_app.rb
|
|
59
|
+
- lib/ask/observability/railtie.rb
|
|
60
|
+
- lib/ask/observability/subscriber.rb
|
|
61
|
+
- lib/ask/observability/version.rb
|
|
62
|
+
- lib/generators/ask/observability/install/templates/initializer.rb.erb
|
|
63
|
+
- lib/generators/ask/observability/install_generator.rb
|
|
64
|
+
homepage: https://github.com/ask-rb/ask-observability
|
|
65
|
+
licenses:
|
|
66
|
+
- MIT
|
|
67
|
+
metadata:
|
|
68
|
+
homepage_uri: https://github.com/ask-rb/ask-observability
|
|
69
|
+
source_code_uri: https://github.com/ask-rb/ask-observability
|
|
70
|
+
changelog_uri: https://github.com/ask-rb/ask-observability/blob/master/CHANGELOG.md
|
|
71
|
+
rubygems_mfa_required: 'true'
|
|
72
|
+
rdoc_options: []
|
|
73
|
+
require_paths:
|
|
74
|
+
- lib
|
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '3.2'
|
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '0'
|
|
85
|
+
requirements: []
|
|
86
|
+
rubygems_version: 4.0.3
|
|
87
|
+
specification_version: 4
|
|
88
|
+
summary: Prometheus metrics, OpenTelemetry, and structured logging for the ask-rb
|
|
89
|
+
ecosystem
|
|
90
|
+
test_files: []
|