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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Observability
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ask/observability'
@@ -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: []