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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +25 -12
- data/contracts/apm-batch-v1.schema.json +51 -1
- data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
- data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
- data/docs/architecture.md +5 -2
- data/docs/compatibility.md +4 -4
- data/docs/configuration.md +22 -1
- data/docs/data-collected.md +9 -3
- data/docs/examples/plain-ruby.md +6 -0
- data/docs/modules/apm-aggregation.md +18 -5
- data/docs/modules/sidekiq-legacy.md +1 -1
- data/docs/modules/sql-monitoring.md +60 -6
- data/docs/modules/telemetry-events.md +1 -1
- data/docs/performance.md +19 -3
- data/docs/privacy-lgpd.md +6 -3
- data/docs/protocol-v1.md +1 -1
- data/docs/release-1.1-readiness.md +51 -0
- data/docs/security-review.md +7 -3
- data/docs/troubleshooting.md +6 -0
- data/lib/chronos/agent.rb +12 -2
- data/lib/chronos/application/apm_aggregator.rb +179 -29
- data/lib/chronos/configuration/apm_validation.rb +51 -1
- data/lib/chronos/configuration.rb +19 -2
- data/lib/chronos/core/metric_aggregate.rb +69 -7
- data/lib/chronos/core/sql_query_analyzer.rb +309 -0
- data/lib/chronos/ports/query_inspector.rb +23 -0
- data/lib/chronos/rails/active_record_query_inspector.rb +235 -0
- data/lib/chronos/rails/notifications_subscriber.rb +163 -2
- data/lib/chronos/rails.rb +1 -0
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +2 -0
- data/lib/generators/chronos/install/templates/chronos.rb +152 -6
- metadata +7 -2
data/docs/performance.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Performance
|
|
2
2
|
|
|
3
|
-
Performance is a functional requirement, but version 1.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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`.
|
data/docs/security-review.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Security review for 1.
|
|
1
|
+
# Security review for 1.1.0
|
|
2
2
|
|
|
3
|
-
Review date: 2026-
|
|
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.
|
data/docs/troubleshooting.md
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
144
|
+
if @transactions.length >= @config.apm_max_groups
|
|
145
|
+
@dropped_trace_trackers += 1
|
|
146
|
+
return nil
|
|
147
|
+
end
|
|
112
148
|
|
|
113
|
-
@transactions[trace_id] = {
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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) }
|