chronos-ruby 1.0.0 → 1.1.1

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.
data/docs/performance.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Performance
2
2
 
3
- Performance is a functional requirement, but version 1.0 makes no unverified speed claim.
3
+ Performance is a functional requirement, but version 1.1 makes no unverified speed claim.
4
4
 
5
5
  Current controls:
6
6
 
@@ -18,9 +18,11 @@ Current controls:
18
18
  - shutdown and flush have caller-controlled timeouts;
19
19
  - request context and breadcrumbs have fixed structural and byte limits;
20
20
  - Rack middleware never consumes request or response bodies.
21
- - Rails subscribers copy only small allowlisted field sets and never copy raw SQL or job arguments.
21
+ - Rails subscribers copy only small allowlisted field sets and never deliver raw SQL or job arguments.
22
22
  - Sidekiq middleware creates no per-job thread or connection and bounds arguments, collections, nesting, strings, and tags before telemetry capture.
23
23
  - APM group, trace, query-fingerprint, histogram, and batch counts are fixed; no APM timer thread is created.
24
+ - active trace trackers survive aggregate drains but expire after `apm_trace_ttl_seconds`; query inspections and transaction connections have independent fixed caps;
25
+ - normalized query analysis is bounded and database inspection is disabled by default; opt-in index/statistics/plan inspection adds database round trips only once per selected fingerprint;
24
26
  - outbound HTTP instrumentation uses two clock reads and bounded metadata without body/header traversal;
25
27
  - cache normalization is bounded and SHA-256 runs only when explicitly enabled;
26
28
  - dependency inventory runs at most once per agent and is capped at 200 loaded specs.
@@ -29,7 +31,21 @@ Current controls:
29
31
 
30
32
  Run the scripts under `benchmarks/` and record Ruby version, operating system, CPU, warmup, iteration count, median, and dispersion before publishing results. `benchmarks/filtering.rb` measures privacy filtering, `benchmarks/retry_backlog.rb` measures fixed-memory outage behavior, `benchmarks/request_overhead.rb` compares Rack-protocol calls, and `benchmarks/rails_notifications.rb` isolates subscriber normalization overhead.
31
33
 
32
- ## Version 1.0.0 release gates
34
+ `benchmarks/query_analysis.rb` compares normalization alone with normalization plus bounded static analysis. It deliberately excludes database inspection because catalog and planner cost must be measured against the actual adapter, schema, statistics, and database host before production enablement.
35
+
36
+ ```bash
37
+ ITERATIONS=100000 WARMUP=5000 bundle exec ruby benchmarks/query_analysis.rb
38
+ ```
39
+
40
+ ## Version 1.1.0 release gates
41
+
42
+ Version 1.1 adds a release-gate benchmark for normalized SQL analysis. Static analysis is calculated once per fingerprint and cached under `apm_query_analysis_max_queries`; database inspection remains disabled in the benchmark because its cost depends on the actual adapter, schema, statistics, network, and database host.
43
+
44
+ The tag workflow runs query analysis with 1,000 warmup and 10,000 measured iterations, then repeats the existing Rack comparison and 500-event fake-endpoint privacy/load gate. Local preparation evidence is recorded in [Version 1.1 readiness](release-1.1-readiness.md). Results are environment-specific and are not a cross-runtime performance promise.
45
+
46
+ The local Ruby 2.2.10 preparation run measured 453.981 µs per normalization, 594.862 µs for the first static analysis of a fingerprint, 43.946 µs median incremental Rack work, and 500/500 fake-endpoint deliveries at 378.15 events/s. Static analysis is cached; the 594.862 µs value is not paid for every repeated fingerprint.
47
+
48
+ ## Historical version 1.0.0 release gates
33
49
 
34
50
  `benchmarks/comparative.rb` compares the same successful Rack fixture without and with Chronos instrumentation. It performs configurable warmup, at least three samples, and reports median plus median absolute deviation. `benchmarks/fake_endpoint_load.rb` sends asynchronous exception events to a local TCP endpoint, verifies the v1 schema marker, ensures the secret key is absent from every payload, and fails on loss, rejection, invalid payload, or timeout.
35
51
 
data/docs/privacy-lgpd.md CHANGED
@@ -15,7 +15,8 @@ Version 0.9 sanitizes exception, framework telemetry, dependency/deploy inventor
15
15
  | Unknown Ruby objects | Represented by class name without calling application serialization |
16
16
  | Request/response bodies, raw query strings, cookies, authorization headers, raw SQL/binds, cache values, mail bodies, environment variables | Never collected automatically |
17
17
  | Sidekiq arguments | Collected automatically, limited before sanitization, then redacted by the common policy |
18
- | SQL | Comments, quoted/numeric/boolean/null literals removed; binds never read; bounded identifiers remain |
18
+ | SQL | Comments, quoted/numeric/boolean/null literals removed; binds never read; bounded identifiers remain; raw SQL is never transmitted |
19
+ | Query inspection | Disabled by default; local `EXPLAIN` has no `ANALYZE`; only allowlisted plan/catalog metadata is retained |
19
20
  | External HTTP | Host/method/status/timing only; URL path/query, Authorization, bodies, headers, and error messages omitted |
20
21
  | Cache key | Omitted by default; optional project-scoped SHA-256 hash; cache value never read |
21
22
  | Dependencies | Bounded loaded gem names/versions and detected runtime labels; paths and lockfiles omitted |
@@ -39,16 +40,18 @@ Raw cache keys can contain user or business data and are never delivered. Option
39
40
 
40
41
  ## Rails telemetry
41
42
 
42
- Rails subscribers use per-notification allowlists. SQL events retain only the operation name, cached flag, and duration; cache events omit key and value; mailer events omit addresses and content; Active Job events omit job ID and arguments; view identifiers are reduced to basenames. Controller parameters are sanitized by the normal payload pipeline before queueing.
43
+ Rails subscribers use per-notification allowlists. SQL events retain normalized value-free metadata, bounded analysis, cached flag, duration and optional allowlisted inspection evidence; cache events omit key and value; mailer events omit addresses and content; Active Job events omit arguments; view identifiers are reduced to basenames. Controller parameters are sanitized by the normal payload pipeline before queueing.
43
44
 
44
45
  ## Sidekiq jobs
45
46
 
46
47
  The optional Sidekiq middleware is the only version 0.6 integration that automatically reads job arguments. It traverses at most 20 top-level arguments, 20 items per nested collection, four levels, and 512 bytes per string. These structural limits run before the common key and content sanitizer. Trace propagation contains only trace and request identifiers. Do not place credentials or unnecessary personal, health, or financial data in job arguments; configure application-specific blocklist keys and audit representative synthetic payloads before enabling production delivery.
47
48
 
48
- ## APM dimensions
49
+ ## APM dimensions and query inspection
49
50
 
50
51
  Metric groups deliberately exclude user IDs, JIDs, raw URLs, request parameters, bind values, exception messages, and cache keys. SQL normalization removes common literal forms and comments before fingerprinting, but retains bounded database identifiers and cannot parse every dialect. Do not encode personal or secret values in schema, table, column, SQL keyword, or operation names. Slow-query source contains a bounded file/line frame under the configured application root, not source-code contents.
51
52
 
53
+ Static query analysis is enabled by default and processes only the normalized SQL. Index, statistics, and plan inspection are separate opt-ins. The original SELECT is used only to ask the same local connection for `EXPLAIN` without `ANALYZE`; it is never placed in telemetry. Plan predicates and arbitrary `Extra`/filter text are discarded. Only bounded node type, relation, index, estimated rows, cost and schema-index definitions cross the sanitizer. These identifiers can still disclose business vocabulary, so production enablement requires a representative synthetic payload audit.
54
+
52
55
  ## Rack context and breadcrumbs
53
56
 
54
57
  The Rack middleware copies only bounded operational fields and parameter hashes that another component already parsed. It does not read `rack.input` or copy `QUERY_STRING`. User context is opt-in through `chronos.user`, and user agent collection is disabled unless the middleware option enables it.
data/docs/protocol-v1.md CHANGED
@@ -2,6 +2,6 @@
2
2
 
3
3
  The schemas under `contracts/` are the source of truth for protocol v1. Version `1.0.0` freezes `schema_version: "1.0"` and treats every required field, enum value, privacy exclusion, and maximum as a compatibility contract.
4
4
 
5
- Compatible changes may add optional bounded fields or new event types accepted by the server. Removing or renaming a field, changing its type/meaning, weakening a bound, or making an optional field required needs a new protocol major schema. Authentication remains outside the JSON payload. Contract tests and `script/verify_docs` must pass before a release.
5
+ Compatible changes may add optional bounded fields or new event types accepted by the server. APM v1 metrics can now carry tracking-loss metadata, approximate percentiles, severity counts, at most 20 structured diagnostics, and one bounded query-analysis object. They remain optional in the schema for compatibility even though this agent version emits them. Consumers should persist diagnostic `code` and `severity` as stable model fields and treat English messages/recommendations as display fallback text. Removing or renaming a field, changing its type/meaning, weakening a bound, or adding a required field without coordinated consumer rollout needs a new protocol major schema. Authentication remains outside the JSON payload. Contract tests and `script/verify_docs` must pass before a release.
6
6
 
7
7
  The explicit integration check uses the normal exception envelope and identifies itself through `context.integration_verification`. Its receiver acknowledgement is governed separately by [`integration-verification-response-v1.schema.json`](../contracts/integration-verification-response-v1.schema.json). A successful HTTP status without a complete, correlated response is not proof of authentication or ingestion.
@@ -0,0 +1,51 @@
1
+ # Version 1.1 release evidence
2
+
3
+ Version `1.1.0` is a backward-compatible minor release of the stable legacy line. It adds bounded SQL diagnostics, optional read-only database inspection, richer query metrics, and safer trace correlation while keeping `schema_version: "1.0"` and the previously validated Ruby/Rails/Sidekiq support matrix.
4
+
5
+ ## Scope decision
6
+
7
+ The long-term roadmap associates a transitional 1.x line with Ruby 2.7 and Rails 6. This release does not claim that support: no runtime/framework pair becomes `Supported` without dedicated CI, a real example application, fake-endpoint delivery, shutdown, privacy, and integration evidence. Version 1.1.0 is therefore a SemVer-compatible capability release for the current legacy package; the transitional matrix remains future work.
8
+
9
+ ## Candidate evidence
10
+
11
+ Local release preparation on 2026-08-05 used Ruby 2.2.10 x86_64 on macOS. The tag workflow must reproduce every supported Linux/container gate before RubyGems publication.
12
+
13
+ | Gate | Local candidate evidence | Tag enforcement |
14
+ |---|---|---|
15
+ | Unit, integration, contract and documentation | 195 examples, zero failures | every legacy core job plus publish `rake` |
16
+ | Ruby style and legacy syntax | 204 Ruby files, zero RuboCop offenses on Ruby 2.2.10 | every legacy core job plus publish `rake` |
17
+ | Query analysis privacy | PostgreSQL/MySQL allowlist specs and final serialized-payload integration test | contract/unit/integration suite |
18
+ | Normalized-query benchmark | 10,000 iterations with warmup; database inspection disabled | `release-readiness` |
19
+ | Legacy Ruby 2.2.10–2.6.10 | locally exercised on 2.2.10; complete result belongs to tag CI | `legacy-core` matrix |
20
+ | Rails 4.2/5.2 applications | no new support claim; complete result belongs to tag CI | `legacy-rails` matrix |
21
+ | Sidekiq 4/5 applications | no new support claim; complete result belongs to tag CI | `legacy-sidekiq` matrix |
22
+ | Repeatable Rack comparison | executed locally with warmup, five samples, median and MAD | `release-readiness` |
23
+ | Fake endpoint load/privacy | 500-event local gate with complete receipt and no secret leakage | `release-readiness` |
24
+ | Package metadata | gem version, contents, checksum and SPDX SBOM verified locally | publish job |
25
+ | Trusted publication | no local push performed | protected RubyGems environment and OIDC |
26
+
27
+ ## Local measurements
28
+
29
+ The 2026-08-05 Ruby 2.2.10 preparation run produced:
30
+
31
+ | Measurement | Result |
32
+ |---|---:|
33
+ | SQL normalization, 10,000 iterations after 1,000 warmup | 453.981 µs/iteration |
34
+ | First bounded static analysis, same fixture | 594.862 µs/iteration |
35
+ | Direct Rack median, 10,000 calls × 5 samples | 0.015203 s; MAD 0.000351 s |
36
+ | Chronos Rack median, same fixture | 0.454667 s; MAD 0.014678 s |
37
+ | Median incremental Rack work | 43.946 µs/request |
38
+ | Fake endpoint | 500/500 received, 1.322240 s, 378.15 events/s |
39
+
40
+ The database-inspection path was not benchmarked because a synthetic adapter would not represent catalog/planner cost. Production opt-in requires an adapter/schema-specific staging measurement.
41
+
42
+ ## Release controls
43
+
44
+ - the tag must be exactly `v1.1.0` and match `Chronos::VERSION`;
45
+ - `publish` depends on the legacy core, Rails, Sidekiq, and release-readiness jobs;
46
+ - query inspection is disabled by default and never uses `EXPLAIN ANALYZE` or DDL;
47
+ - all new APM v1 properties remain optional in the schema for older consumers;
48
+ - artifacts contain the gem, SHA-256 checksum, and SPDX SBOM;
49
+ - a failed, skipped, or incomplete supported-matrix job blocks publication.
50
+
51
+ The local artifact is evidence only. Publication must happen through the tag workflow, not with a manual `gem push`.
@@ -1,6 +1,6 @@
1
- # Security review for 1.0.0
1
+ # Security review for 1.1.0
2
2
 
3
- Review date: 2026-07-29. Scope: capture, serialization, transport, integration verification, remote configuration, framework/job integrations, stable release workflow, examples, and fixtures.
3
+ Review date: 2026-08-05. Scope: capture, serialization, transport, query analysis/inspection, integration verification, remote configuration, framework/job integrations, stable release workflow, examples, and fixtures.
4
4
 
5
5
  Verified by contracts and implementation review:
6
6
 
@@ -13,5 +13,9 @@ Verified by contracts and implementation review:
13
13
  - Active Job propagation uses a namespaced v1 field containing only bounded trace/request identifiers and does not alter job arguments;
14
14
  - fixture privacy is enforced by contract tests and dependency advisories are checked by the security workflow.
15
15
  - integration verification accepts only a strict correlated response and never exposes raw receiver bodies, credentials, stack traces, paths, SQL, or internal architecture.
16
+ - normalized query analysis is bounded by fingerprint; raw SQL and binds are never transmitted;
17
+ - database inspection is disabled by default, read-only, limited by duration/fingerprint, and never executes `EXPLAIN ANALYZE` or DDL;
18
+ - plan predicates, arbitrary planner text, database rows, and exception messages are excluded; only allowlisted schema/planner fields and error classes remain;
19
+ - new APM properties are additive/optional under protocol v1, and diagnostics/evidence pass through the common sanitizer and serializer limits.
16
20
 
17
- Residual risks: supported Ruby/Rails versions are end-of-life; in-memory backlog is lost at exit; application filters/ignore rules execute application code; project identifiers and documented job IDs may be personal data in some deployments; package signing is not enabled because no trusted certificate/key lifecycle exists. Stable artifacts use a protected environment, Trusted Publishing, SHA-256 checksums and SPDX SBOMs until signing can be operated safely.
21
+ Residual risks: supported Ruby/Rails versions are end-of-life; opt-in catalog and planner calls add database work and lack one portable timeout across legacy adapters; static index recommendations remain heuristic; in-memory backlog is lost at exit; application filters/ignore rules execute application code; project/schema identifiers and documented job IDs may be personal data in some deployments; package signing is not enabled because no trusted certificate/key lifecycle exists. Stable artifacts use a protected environment, Trusted Publishing, SHA-256 checksums and SPDX SBOMs until signing can be operated safely.
@@ -74,6 +74,12 @@ This is intentional. The normalizer removes common literal forms and never reads
74
74
 
75
75
  Local detectors emit bounded heuristics, not confirmed diagnoses. Repetition can be legitimate and exception class names can be adapter-specific. Confirm the trace in the SaaS and application logs before changing application behavior.
76
76
 
77
+ ## Index recommendation is inaccurate or inspection reports an error
78
+
79
+ An `index_candidate` uses normalized SQL only and has low confidence. `missing_index_candidate` means the observed index prefix was absent from the bounded catalog snapshot; it does not evaluate selectivity, write cost, partial/expression indexes, storage, or production data distribution. Validate with a DBA and the actual workload before creating a migration.
80
+
81
+ Inspection is disabled by default. Enable `apm_query_inspection_enabled` first in staging, keep the fingerprint and duration limits small, then opt into statistics or plans. `query_inspection_failed` contains only the exception class. Unsupported adapters, prepared placeholders, permissions, planner timeouts, or unavailable catalog APIs can cause it. The gem never retries an inspection or falls back to `EXPLAIN ANALYZE`.
82
+
77
83
  ## Context appears missing
78
84
 
79
85
  The legacy context store is thread-local. A new application-created thread does not inherit context. Establish a new `Chronos.with_context` scope inside that thread, and issue manual notification before the scope exits. For Rack capture, supply user and explicit parameters through the documented environment keys.
data/lib/chronos/agent.rb CHANGED
@@ -11,7 +11,7 @@ module Chronos
11
11
  # agent.notify(RuntimeError.new("failed"))
12
12
  # @errors Capture errors return false; explicit construction requires valid configuration.
13
13
  # @performance No worker threads are created until the first asynchronous event.
14
- class Agent
14
+ class Agent # rubocop:disable Metrics/ClassLength
15
15
  DEFAULT_FLUSH_TIMEOUT = 5.0
16
16
 
17
17
  attr_reader :config
@@ -133,7 +133,17 @@ module Chronos
133
133
  {
134
134
  :enabled => @config.apm_enabled,
135
135
  :slow_query_threshold_ms => @config.apm_slow_query_threshold_ms,
136
- :root_directory => @config.root_directory
136
+ :root_directory => @config.root_directory,
137
+ :query_analysis_enabled => @config.apm_query_analysis_enabled,
138
+ :query_analysis_max_queries => @config.apm_query_analysis_max_queries,
139
+ :query_inspection_enabled => @config.apm_query_inspection_enabled,
140
+ :query_statistics_enabled => @config.apm_query_statistics_enabled,
141
+ :query_plan_enabled => @config.apm_query_plan_enabled,
142
+ :query_inspection_min_duration_ms => @config.apm_query_inspection_min_duration_ms,
143
+ :query_inspection_max_queries => @config.apm_query_inspection_max_queries,
144
+ :transaction_tracking_enabled => @config.apm_transaction_tracking_enabled,
145
+ :transaction_max_connections => @config.apm_transaction_max_connections,
146
+ :transaction_ttl_seconds => @config.apm_trace_ttl_seconds
137
147
  }
138
148
  end
139
149
 
@@ -13,28 +13,51 @@ module Chronos
13
13
  # batches = aggregator.flush
14
14
  # @errors Invalid observations are ignored and never escape to the application.
15
15
  # @performance Group, transaction, query, bucket, and batch counts are strictly bounded.
16
- class ApmAggregator
16
+ class ApmAggregator # rubocop:disable Metrics/ClassLength
17
17
  include ApmErrorClassifier
18
18
 
19
19
  METRIC_TYPES = %w(request query job external_http).freeze
20
- def initialize(config)
20
+ QUERY_ERROR_SIGNALS = {
21
+ "connection_error" => /Connection|NoDatabase|Adapter/i,
22
+ "deadlock" => /Deadlock/i,
23
+ "query_timeout" => /StatementTimeout|QueryCanceled|QueryTimeout|Timeout/i,
24
+ "pool_timeout" => /ConnectionTimeout|PoolTimeout/i,
25
+ "lock_timeout" => /LockWaitTimeout|LockTimeout/i,
26
+ "constraint_violation" => /RecordNotUnique|NotNullViolation|ForeignKeyViolation|InvalidForeignKey|Constraint/i
27
+ }.freeze
28
+ SIGNAL_DIAGNOSTICS = {
29
+ "slow_query" => ["warning", "performance", "Query duration reached the slow threshold"].freeze,
30
+ "long_transaction" => ["warning", "transaction", "Transaction SQL reached the long threshold"].freeze,
31
+ "connection_error" => ["error", "connection", "Database connection failed"].freeze,
32
+ "deadlock" => ["error", "locking", "The database reported a deadlock"].freeze,
33
+ "query_timeout" => ["error", "timeout", "The database query timed out"].freeze,
34
+ "pool_timeout" => ["error", "connection_pool", "Database connection-pool checkout timed out"].freeze,
35
+ "lock_timeout" => ["error", "locking", "The database lock wait timed out"].freeze,
36
+ "constraint_violation" => ["error", "constraint", "The database rejected a constraint"].freeze
37
+ }.freeze
38
+ def initialize(config, options = {})
21
39
  @config = config
40
+ @clock = options[:clock] || proc { Time.now.to_f }
22
41
  @mutex = Mutex.new
23
42
  @groups = {}
24
43
  @transactions = {}
25
44
  @observations = 0
26
45
  @dropped_groups = 0
46
+ @dropped_trace_trackers = 0
47
+ @expired_trace_trackers = 0
48
+ @dropped_query_fingerprints = 0
27
49
  end
28
50
 
29
51
  def record(event_type, payload = {}, context = {})
30
52
  return [] unless @config.apm_enabled
31
53
 
32
54
  @mutex.synchronize do
55
+ expire_transactions
33
56
  type = event_type.to_s
34
57
  data = hash(payload)
35
58
  execution = hash(context)
36
- observe_component(type, data, execution)
37
- add_metric(type, data, execution) if aggregate_metric?(type, data)
59
+ correlation = observe_component(type, data, execution)
60
+ add_metric(type, data, execution, correlation) if aggregate_metric?(type, data)
38
61
  @observations += 1 if aggregate_metric?(type, data)
39
62
  @observations >= @config.apm_flush_count ? drain_locked : []
40
63
  end
@@ -53,6 +76,9 @@ module Chronos
53
76
  {
54
77
  "groups" => @groups.length, "dropped_groups" => @dropped_groups,
55
78
  "transactions" => @transactions.length,
79
+ "dropped_trace_trackers" => @dropped_trace_trackers,
80
+ "expired_trace_trackers" => @expired_trace_trackers,
81
+ "dropped_query_fingerprints" => @dropped_query_fingerprints,
56
82
  "tracked_queries" => @transactions.values.inject(0) do |total, transaction|
57
83
  total + transaction["queries"].length
58
84
  end
@@ -66,7 +92,7 @@ module Chronos
66
92
  METRIC_TYPES.include?(type) && !(type == "request" && payload["kind"].to_s == "view")
67
93
  end
68
94
 
69
- def add_metric(type, payload, context)
95
+ def add_metric(type, payload, context, correlation)
70
96
  dimensions = dimensions_for(type, payload)
71
97
  key = metric_key(type, dimensions)
72
98
  aggregate = @groups[key]
@@ -80,53 +106,77 @@ module Chronos
80
106
  end
81
107
  duration = non_negative(payload["duration_ms"] || payload[:duration_ms])
82
108
  status = ["request", "external_http"].include?(type) ? payload["status"] || payload[:status] : nil
83
- signals = signals_for(type, payload, context, duration)
109
+ signals = signals_for(type, payload, context, duration, correlation)
110
+ diagnostics = diagnostics_for(type, payload, context, signals, correlation)
84
111
  breakdown = breakdown_for(type, payload, context, duration)
85
112
  aggregate.observe(
86
- duration, error?(type, payload), breakdown, signals, status
113
+ duration, error?(type, payload), breakdown, signals,
114
+ :status => status, :diagnostics => diagnostics,
115
+ :query_analysis => type == "query" ? hash(payload["analysis"] || payload[:analysis]) : {}
87
116
  )
88
117
  end
89
118
 
90
119
  def observe_component(type, payload, context)
91
- return if type == "job"
120
+ return {} if type == "job"
92
121
 
93
122
  trace_id = trace_id(context)
94
- return if trace_id.empty?
123
+ return {} if trace_id.empty?
95
124
 
96
125
  transaction = transaction_for(trace_id)
97
- return unless transaction
126
+ return {} unless transaction
127
+
128
+ transaction["last_seen_at"] = now
98
129
 
99
130
  category = component_category(type, payload)
100
131
  return observe_query(transaction, payload) if type == "query" && category.nil?
101
- return unless category
132
+ return {} unless category
102
133
 
103
134
  transaction["breakdown_ms"][category] ||= 0.0
104
135
  transaction["breakdown_ms"][category] += non_negative(payload["duration_ms"] || payload[:duration_ms])
105
- observe_query(transaction, payload) if type == "query"
136
+ return observe_query(transaction, payload) if type == "query"
137
+
138
+ {}
106
139
  end
107
140
 
108
141
  def transaction_for(trace_id)
109
142
  existing = @transactions[trace_id]
110
143
  return existing if existing
111
- return nil if @transactions.length >= @config.apm_max_groups
144
+ if @transactions.length >= @config.apm_max_groups
145
+ @dropped_trace_trackers += 1
146
+ return nil
147
+ end
112
148
 
113
- @transactions[trace_id] = {"breakdown_ms" => {}, "signals" => {}, "queries" => {}}
149
+ @transactions[trace_id] = {
150
+ "breakdown_ms" => {}, "signals" => {}, "queries" => {}, "diagnostics" => [],
151
+ "last_seen_at" => now
152
+ }
114
153
  end
115
154
 
116
155
  def observe_query(transaction, payload)
117
156
  fingerprint = (payload["fingerprint"] || payload[:fingerprint]).to_s
118
- return if fingerprint.empty?
157
+ return {} if fingerprint.empty?
119
158
 
120
159
  queries = transaction["queries"]
121
- return unless queries.key?(fingerprint) || queries.length < @config.apm_max_queries_per_request
160
+ unless queries.key?(fingerprint) || queries.length < @config.apm_max_queries_per_request
161
+ @dropped_query_fingerprints += 1
162
+ return {}
163
+ end
122
164
 
123
165
  queries[fingerprint] ||= 0
124
166
  queries[fingerprint] += 1
125
167
  count = queries[fingerprint]
126
168
  slow = non_negative(payload["duration_ms"]) >= @config.apm_slow_query_threshold_ms
127
169
  increment_signal(transaction, "slow_query") if slow
128
- increment_signal(transaction, "repeated_query") if count > 1
129
- increment_signal(transaction, "possible_n_plus_one") if count == @config.apm_n_plus_one_threshold
170
+ current = {}
171
+ if count > 1
172
+ increment_signal(transaction, "repeated_query")
173
+ current["repeated_query"] = 1
174
+ end
175
+ if n_plus_one_candidate?(payload) && count == @config.apm_n_plus_one_threshold
176
+ increment_signal(transaction, "possible_n_plus_one")
177
+ current["possible_n_plus_one"] = 1
178
+ end
179
+ {"signals" => current, "query_count" => count}
130
180
  end
131
181
 
132
182
  def breakdown_for(type, payload, context, duration)
@@ -145,13 +195,75 @@ module Chronos
145
195
  explicit
146
196
  end
147
197
 
148
- def signals_for(type, payload, context, duration)
198
+ def signals_for(type, payload, context, duration, correlation)
149
199
  return request_signals(context) if type == "request"
150
- return query_signals(payload, duration) if type == "query"
200
+ return merge_integer_signals(query_signals(payload, duration), hash(correlation["signals"])) if type == "query"
151
201
 
152
202
  {}
153
203
  end
154
204
 
205
+ def diagnostics_for(type, payload, context, signals, correlation)
206
+ return request_diagnostics(context) if type == "request"
207
+ return [] unless type == "query"
208
+
209
+ analysis = hash(payload["analysis"] || payload[:analysis])
210
+ result = Array(analysis["diagnostics"] || analysis[:diagnostics]).first(20)
211
+ result += signal_diagnostics(signals, correlation)
212
+ error_class = (payload["error_class"] || payload[:error_class]).to_s
213
+ unless error_class.empty?
214
+ result << diagnostic("query_execution_error", "error", "execution",
215
+ "The database query raised an exception", "error_class" => error_class[0, 128])
216
+ end
217
+ retain_transaction_diagnostics(context, result)
218
+ result.first(20)
219
+ end
220
+
221
+ def signal_diagnostics(signals, correlation)
222
+ result = signal_error_diagnostics(signals)
223
+ if hash(correlation["signals"])["repeated_query"]
224
+ result << diagnostic("repeated_query", "info", "query_pattern",
225
+ "The same query fingerprint repeated in one trace",
226
+ "occurrence" => correlation["query_count"])
227
+ end
228
+ if hash(correlation["signals"])["possible_n_plus_one"]
229
+ result << diagnostic("possible_n_plus_one", "warning", "n_plus_one",
230
+ "Repeated SELECT reached the N+1 threshold",
231
+ "occurrence" => correlation["query_count"])
232
+ item = diagnostic("n_plus_one_eager_loading", "suggestion", "n_plus_one",
233
+ "Review eager loading or batch loading for this query fingerprint")
234
+ item["recommendation"] = "Use includes, preload, eager_load, or an equivalent bounded batch query " \
235
+ "when semantics allow"
236
+ result << item
237
+ end
238
+ result
239
+ end
240
+
241
+ def signal_error_diagnostics(signals)
242
+ SIGNAL_DIAGNOSTICS.each_with_object([]) do |(code, values), result|
243
+ result << diagnostic(code, values[0], values[1], values[2]) if signals[code]
244
+ end
245
+ end
246
+
247
+ def diagnostic(code, severity, category, message, evidence = {})
248
+ {"code" => code, "severity" => severity, "category" => category,
249
+ "message" => message, "evidence" => evidence}
250
+ end
251
+
252
+ def retain_transaction_diagnostics(context, diagnostics)
253
+ transaction = @transactions[trace_id(context)]
254
+ return unless transaction
255
+
256
+ diagnostics.each do |item|
257
+ next if transaction["diagnostics"].any? { |existing| existing["code"] == item["code"] }
258
+ transaction["diagnostics"] << item if transaction["diagnostics"].length < 20
259
+ end
260
+ end
261
+
262
+ def request_diagnostics(context)
263
+ transaction = @transactions[trace_id(context)]
264
+ transaction ? transaction["diagnostics"].dup : []
265
+ end
266
+
155
267
  def request_signals(context)
156
268
  transaction = @transactions[trace_id(context)]
157
269
  transaction ? transaction["signals"].dup : {}
@@ -162,16 +274,30 @@ module Chronos
162
274
  signals["slow_query"] = 1 if duration >= @config.apm_slow_query_threshold_ms
163
275
  signals["long_transaction"] = 1 if long_transaction?(payload, duration)
164
276
  error_class = (payload["error_class"] || payload[:error_class]).to_s
165
- signals["connection_error"] = 1 if error_class =~ /Connection|NoDatabase|Adapter/i
166
- signals["deadlock"] = 1 if error_class =~ /Deadlock/i
277
+ QUERY_ERROR_SIGNALS.each do |name, pattern|
278
+ signals[name] = 1 if error_class =~ pattern
279
+ end
167
280
  signals
168
281
  end
169
282
 
170
283
  def long_transaction?(payload, duration)
171
284
  operation = (payload["operation"] || payload[:operation]).to_s
172
285
  name = (payload["name"] || payload[:name]).to_s
173
- transaction = operation == "BEGIN" || operation == "COMMIT" || name =~ /TRANSACTION/i
174
- transaction && duration >= @config.apm_long_transaction_threshold_ms
286
+ transaction = operation == "BEGIN" || operation == "COMMIT" || operation == "ROLLBACK" || name =~ /TRANSACTION/i
287
+ measured = non_negative(payload["transaction_duration_ms"] || payload[:transaction_duration_ms] || duration)
288
+ transaction && measured >= @config.apm_long_transaction_threshold_ms
289
+ end
290
+
291
+ def n_plus_one_candidate?(payload)
292
+ operation = (payload["operation"] || payload[:operation]).to_s
293
+ cached = payload.key?("cached") ? payload["cached"] : payload[:cached]
294
+ operation == "SELECT" && cached != true
295
+ end
296
+
297
+ def merge_integer_signals(left, right)
298
+ result = left.dup
299
+ right.each { |key, value| result[key.to_s] = result.fetch(key.to_s, 0) + value.to_i }
300
+ result
175
301
  end
176
302
 
177
303
  def dimensions_for(type, payload)
@@ -238,23 +364,47 @@ module Chronos
238
364
 
239
365
  def drain_locked
240
366
  metrics = @groups.values.map(&:to_h)
241
- if metrics.empty?
242
- @transactions = {}
243
- return []
244
- end
367
+ return [] if metrics.empty?
245
368
 
246
369
  dropped = @dropped_groups
247
370
  @groups = {}
248
- @transactions = {}
249
371
  @observations = 0
250
372
  @dropped_groups = 0
373
+ tracking = {
374
+ "active_traces" => @transactions.length,
375
+ "dropped_trace_trackers" => @dropped_trace_trackers,
376
+ "expired_trace_trackers" => @expired_trace_trackers,
377
+ "dropped_query_fingerprints" => @dropped_query_fingerprints
378
+ }
379
+ @dropped_trace_trackers = 0
380
+ @expired_trace_trackers = 0
381
+ @dropped_query_fingerprints = 0
251
382
  batches = []
252
383
  metrics.each_slice(@config.apm_batch_size) do |slice|
384
+ slice.first["tracking"] = batches.empty? ? tracking : empty_tracking
253
385
  batches << {"metrics" => slice, "dropped_groups" => batches.empty? ? dropped : 0}
254
386
  end
255
387
  batches
256
388
  end
257
389
 
390
+ def expire_transactions
391
+ threshold = now - @config.apm_trace_ttl_seconds.to_f
392
+ expired = @transactions.keys.select { |key| @transactions[key]["last_seen_at"].to_f < threshold }
393
+ expired.each { |key| @transactions.delete(key) }
394
+ @expired_trace_trackers += expired.length
395
+ end
396
+
397
+ def empty_tracking
398
+ {"active_traces" => @transactions.length, "dropped_trace_trackers" => 0,
399
+ "expired_trace_trackers" => 0, "dropped_query_fingerprints" => 0}
400
+ end
401
+
402
+ def now
403
+ @clock.call.to_f
404
+ rescue StandardError
405
+ Time.now.to_f
406
+ end
407
+
258
408
  def non_negative(value)
259
409
  number = value.to_f
260
410
  number < 0.0 ? 0.0 : number
@@ -9,7 +9,7 @@ module Chronos
9
9
  # @thread_safety Reads one mutable configuration instance without shared state.
10
10
  # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
11
  # @errors Invalid values become messages and never raise from validation.
12
- module ApmConfigurationValidation
12
+ module ApmConfigurationValidation # rubocop:disable Metrics/ModuleLength
13
13
  private
14
14
 
15
15
  def apm_errors
@@ -32,6 +32,24 @@ module Chronos
32
32
  unless positive_integer?(apm_max_queries_per_request)
33
33
  errors << "apm_max_queries_per_request must be a positive integer"
34
34
  end
35
+ errors.concat(query_capacity_errors)
36
+ errors
37
+ end
38
+
39
+ def query_capacity_errors
40
+ errors = []
41
+ unless apm_query_analysis_max_queries.is_a?(Integer) &&
42
+ apm_query_analysis_max_queries >= 1 && apm_query_analysis_max_queries <= 500
43
+ errors << "apm_query_analysis_max_queries must be between 1 and 500"
44
+ end
45
+ unless apm_query_inspection_max_queries.is_a?(Integer) &&
46
+ apm_query_inspection_max_queries >= 1 && apm_query_inspection_max_queries <= 100
47
+ errors << "apm_query_inspection_max_queries must be between 1 and 100"
48
+ end
49
+ unless apm_transaction_max_connections.is_a?(Integer) &&
50
+ apm_transaction_max_connections >= 1 && apm_transaction_max_connections <= 500
51
+ errors << "apm_transaction_max_connections must be between 1 and 500"
52
+ end
35
53
  errors
36
54
  end
37
55
 
@@ -46,9 +64,41 @@ module Chronos
46
64
  unless apm_n_plus_one_threshold.is_a?(Integer) && apm_n_plus_one_threshold >= 2
47
65
  errors << "apm_n_plus_one_threshold must be an integer greater than or equal to 2"
48
66
  end
67
+ errors.concat(query_threshold_errors)
49
68
  errors
50
69
  end
51
70
 
71
+ def query_threshold_errors
72
+ errors = []
73
+ unless positive_number?(apm_trace_ttl_seconds)
74
+ errors << "apm_trace_ttl_seconds must be greater than zero"
75
+ end
76
+ unless non_negative_number?(apm_query_inspection_min_duration_ms)
77
+ errors << "apm_query_inspection_min_duration_ms must be zero or greater"
78
+ end
79
+ query_analysis_boolean_attributes.each do |name, value|
80
+ errors << "#{name} must be true or false" unless boolean?(value)
81
+ end
82
+ if (apm_query_statistics_enabled || apm_query_plan_enabled) && !apm_query_inspection_enabled
83
+ errors << "query statistics and plans require apm_query_inspection_enabled"
84
+ end
85
+ errors
86
+ end
87
+
88
+ def query_analysis_boolean_attributes
89
+ {
90
+ :apm_query_analysis_enabled => apm_query_analysis_enabled,
91
+ :apm_query_inspection_enabled => apm_query_inspection_enabled,
92
+ :apm_query_statistics_enabled => apm_query_statistics_enabled,
93
+ :apm_query_plan_enabled => apm_query_plan_enabled,
94
+ :apm_transaction_tracking_enabled => apm_transaction_tracking_enabled
95
+ }
96
+ end
97
+
98
+ def non_negative_number?(value)
99
+ value.is_a?(Numeric) && value >= 0
100
+ end
101
+
52
102
  def increasing_positive_numbers?(values)
53
103
  return false unless values.is_a?(Array) && !values.empty? && values.length <= 19
54
104
  return false unless values.all? { |value| positive_number?(value) }