flare 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -1
  3. data/app/controllers/flare/jobs_controller.rb +1 -1
  4. data/app/controllers/flare/requests_controller.rb +1 -1
  5. data/app/controllers/flare/spans_controller.rb +1 -1
  6. data/app/helpers/flare/application_helper.rb +23 -0
  7. data/app/views/flare/jobs/index.html.erb +2 -1
  8. data/app/views/flare/requests/index.html.erb +2 -1
  9. data/app/views/flare/requests/show.html.erb +16 -7
  10. data/app/views/flare/spans/index.html.erb +2 -1
  11. data/lib/flare/cli/doctor_command.rb +4 -11
  12. data/lib/flare/cli/local_spans.rb +110 -0
  13. data/lib/flare/cli/nplus1_command.rb +97 -0
  14. data/lib/flare/cli/project.rb +28 -0
  15. data/lib/flare/cli/request_command.rb +120 -0
  16. data/lib/flare/cli/requests_command.rb +45 -0
  17. data/lib/flare/cli/setup_command.rb +64 -41
  18. data/lib/flare/cli/status_command.rb +5 -2
  19. data/lib/flare/cli.rb +12 -0
  20. data/lib/flare/client_headers.rb +7 -3
  21. data/lib/flare/configuration.rb +20 -0
  22. data/lib/flare/export_filter.rb +273 -0
  23. data/lib/flare/filtering_span_processor.rb +14 -0
  24. data/lib/flare/http_transport.rb +4 -0
  25. data/lib/flare/metric_span_processor.rb +51 -35
  26. data/lib/flare/metric_submitter.rb +9 -1
  27. data/lib/flare/recording_batch_span_processor.rb +9 -1
  28. data/lib/flare/redis_statement_processor.rb +29 -0
  29. data/lib/flare/revision.rb +82 -0
  30. data/lib/flare/source_location.rb +21 -16
  31. data/lib/flare/sql_mask.rb +235 -0
  32. data/lib/flare/sqlite_exporter.rb +12 -8
  33. data/lib/flare/storage/sqlite.rb +195 -144
  34. data/lib/flare/storage.rb +1 -40
  35. data/lib/flare/store_statement.rb +136 -0
  36. data/lib/flare/trace_blob.rb +9 -5
  37. data/lib/flare/trace_exporter.rb +18 -3
  38. data/lib/flare/upload_url_pool.rb +2 -15
  39. data/lib/flare/utf8.rb +20 -0
  40. data/lib/flare/version.rb +1 -1
  41. data/lib/flare.rb +21 -2
  42. data/public/flare-assets/flare.css +10 -60
  43. metadata +45 -32
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ require_relative "sql_mask"
7
+ require_relative "utf8"
8
+
9
+ module Flare
10
+ # The shape of a Redis or MongoDB statement with its values taken out. Keeps what
11
+ # groups calls together: the command, a key's first segment, a document's
12
+ # structure. Anything it can't read comes out as a placeholder.
13
+ module StoreStatement
14
+ PLACEHOLDER = SqlMask::PLACEHOLDER
15
+ REDIS_SYSTEM = /redis|valkey/i
16
+ MORE = "..."
17
+ MAX_REDIS_LINES = 10
18
+ MAX_REDIS_BYTES = 16_384
19
+ MAX_MONGO_BYTES = 65_536
20
+ MAX_MONGO_DEPTH = 16
21
+ MAX_MONGO_NODES = 5_000
22
+
23
+ KEY_NAMESPACE = /\A[A-Za-z][A-Za-z_-]{0,40}[\/:]/
24
+
25
+ # Commands whose first argument is a key.
26
+ KEY_COMMANDS = %w[
27
+ APPEND DECR DECRBY DEL EXISTS EXPIRE EXPIREAT GET GETDEL GETEX GETRANGE GETSET HDEL HEXISTS HGET HGETALL
28
+ HINCRBY HINCRBYFLOAT HKEYS HLEN HMGET HMSET HSCAN HSET HSETNX HSTRLEN HVALS INCR INCRBY INCRBYFLOAT LINDEX
29
+ LINSERT LLEN LMOVE LPOP LPOS LPUSH LPUSHX LRANGE LREM LSET LTRIM MGET MSET MSETNX PERSIST PEXPIRE PEXPIREAT
30
+ PFADD PFCOUNT PSETEX PTTL RENAME RENAMENX RPOP RPUSH RPUSHX SADD SCARD SDIFF SET SETEX SETNX SETRANGE SINTER
31
+ SISMEMBER SMEMBERS SMISMEMBER SPOP SRANDMEMBER SREM SSCAN STRLEN SUBSTR SUNION TOUCH TTL TYPE UNLINK XACK XADD
32
+ XDEL XLEN XRANGE XREVRANGE XTRIM ZADD ZCARD ZCOUNT ZINCRBY ZLEXCOUNT ZMSCORE ZPOPMAX ZPOPMIN ZRANGE
33
+ ZRANGEBYLEX ZRANGEBYSCORE ZRANK ZREM ZREMRANGEBYRANK ZREMRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZREVRANK
34
+ ZSCAN ZSCORE
35
+ ].to_set.freeze
36
+
37
+ # Commands whose arguments aren't kept.
38
+ OTHER_COMMANDS = %w[
39
+ ACL ASKING AUTH BGSAVE BITCOUNT BLMOVE BLPOP BRPOP BZPOPMAX BZPOPMIN CLIENT CLUSTER COMMAND CONFIG COPY DBSIZE
40
+ DISCARD DUMP ECHO EVAL EVALSHA EVALSHA_RO EVAL_RO EXEC FCALL FLUSHALL FLUSHDB FUNCTION GEOADD GEODIST GEOHASH
41
+ GEOPOS GEOSEARCH GETBIT HELLO INFO KEYS LASTSAVE MEMORY MIGRATE MODULE MONITOR MOVE MULTI OBJECT PING PSUBSCRIBE
42
+ PUBLISH PUBSUB PUNSUBSCRIBE QUIT RANDOMKEY READONLY READWRITE RESET RESTORE ROLE SAVE SCAN SCRIPT SELECT SETBIT
43
+ SHUTDOWN SLAVEOF SLOWLOG SORT SPUBLISH SSUBSCRIBE SUBSCRIBE SUNSUBSCRIBE SWAPDB TIME UNSUBSCRIBE UNWATCH WAIT
44
+ WATCH XAUTOCLAIM XCLAIM XGROUP XINFO XPENDING XREAD XREADGROUP
45
+ ].to_set.freeze
46
+ MODULE_COMMAND = /\A[A-Z]+\.[A-Z]+\z/
47
+
48
+ COLLECTION_COMMANDS = %w[
49
+ find count distinct aggregate insert update delete findAndModify findandmodify getMore create drop listIndexes
50
+ createIndexes dropIndexes
51
+ ].to_set.freeze
52
+ COLLECTION_NAME = /\A[\w.-]{1,64}\z/
53
+ MONGO_KEY = /\A\$?[A-Za-z_][\w-]{0,63}\z/
54
+
55
+ class TooLarge < StandardError; end
56
+
57
+ module_function
58
+
59
+ def redis_system?(system)
60
+ REDIS_SYSTEM.match?(system.to_s)
61
+ end
62
+
63
+ # A key's leading word and separator, like views/? or session:?.
64
+ def masked_key(key)
65
+ "#{key[KEY_NAMESPACE]}#{PLACEHOLDER}"
66
+ end
67
+
68
+ # The instrumentation joins a pipeline's commands with line breaks and a
69
+ # command's arguments with spaces, so a line break can also sit inside a value.
70
+ # Several lines therefore keep only command names, and only lines that start
71
+ # with one count. A script can't be told from its arguments, so it's left out.
72
+ def redis(statement, pipelined: true)
73
+ text = UTF8.scrub(statement.to_s.byteslice(0, MAX_REDIS_BYTES))
74
+ lines = pipelined ? text.split("\n") : [text.split("\n", 2).first.to_s]
75
+
76
+ keep_key = lines.size == 1
77
+ shapes = lines.filter_map { |line| redis_line(line, keep_key) }.chunk_while { |a, b| a == b }.map(&:first)
78
+ return PLACEHOLDER if shapes.empty?
79
+
80
+ kept = shapes.first(MAX_REDIS_LINES)
81
+ kept << MORE if lines.include?(MORE) || shapes.size > MAX_REDIS_LINES
82
+ kept.join("\n")
83
+ end
84
+
85
+ def redis_line(line, keep_key)
86
+ command, key = line.split(" ", 3)
87
+ return unless command
88
+
89
+ command = command.upcase
90
+ key_command = KEY_COMMANDS.include?(command)
91
+ return unless key_command || OTHER_COMMANDS.include?(command) || MODULE_COMMAND.match?(command)
92
+ return command unless key
93
+
94
+ "#{command} #{keep_key && key_command ? masked_key(key) : PLACEHOLDER}"
95
+ end
96
+
97
+ # Keeps the structure of a command's JSON and masks every value. Only a leading
98
+ # command keeps its value, the collection name.
99
+ def mongo(statement)
100
+ text = statement.to_s
101
+ return PLACEHOLDER if text.empty? || text.bytesize > MAX_MONGO_BYTES
102
+
103
+ document = JSON.parse(text, max_nesting: MAX_MONGO_DEPTH)
104
+ return PLACEHOLDER unless document.is_a?(Hash)
105
+
106
+ budget = [MAX_MONGO_NODES]
107
+ masked = mongo_value(document, budget)
108
+ command, collection = document.first
109
+ if COLLECTION_COMMANDS.include?(command) && collection.is_a?(String) && COLLECTION_NAME.match?(collection)
110
+ masked[mongo_key(command)] = collection
111
+ end
112
+ JSON.generate(masked)
113
+ rescue JSON::ParserError, EncodingError, TooLarge, ArgumentError
114
+ PLACEHOLDER
115
+ end
116
+
117
+ def mongo_value(value, budget)
118
+ raise TooLarge if (budget[0] -= 1).negative?
119
+
120
+ case value
121
+ when Hash then value.each_with_object({}) { |(key, item), out| out[mongo_key(key)] = mongo_value(item, budget) }
122
+ when Array then value.map { |item| mongo_value(item, budget) }.uniq
123
+ else PLACEHOLDER
124
+ end
125
+ end
126
+
127
+ def mongo_key(key)
128
+ head, dot, = key.partition(".")
129
+ return PLACEHOLDER unless MONGO_KEY.match?(head)
130
+
131
+ dot.empty? ? head : "#{head}.#{PLACEHOLDER}"
132
+ end
133
+
134
+ private_class_method :redis_line, :mongo_value, :mongo_key
135
+ end
136
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "time"
4
4
 
5
+ require_relative "export_filter"
6
+ require_relative "utf8"
7
+
5
8
  module Flare
6
9
  # Value object that turns a group of OTel span_data for a single trace
7
10
  # into the Flare-JSON wire format the server expects:
@@ -33,18 +36,19 @@ module Flare
33
36
  def initialize(trace_id:, spans:)
34
37
  @trace_id = trace_id
35
38
  @spans = spans
39
+ @filter = ExportFilter.current
36
40
  end
37
41
 
38
42
  def to_h
39
43
  root = find_root
40
- {
44
+ UTF8.scrub({
41
45
  "trace_id" => hexify(@trace_id),
42
46
  "trace_rule_id" => rule_id_from_spans,
43
47
  "root_name" => root_name(root),
44
48
  "started_at" => iso(root&.start_timestamp),
45
49
  "duration_ms" => duration_ms(root),
46
50
  "spans" => @spans.map { |s| span_to_h(s) }
47
- }
51
+ })
48
52
  end
49
53
 
50
54
  private
@@ -84,17 +88,17 @@ module Flare
84
88
  end
85
89
 
86
90
  def root_name(root)
87
- root&.name&.to_s&.slice(0, ROOT_NAME_LIMIT)
91
+ @filter.name(root&.name&.to_s)&.slice(0, ROOT_NAME_LIMIT)
88
92
  end
89
93
 
90
94
  def span_to_h(span)
91
95
  {
92
96
  "id" => hexify(span.span_id),
93
97
  "parent_id" => root?(span) ? nil : hexify(span.parent_span_id),
94
- "name" => span.name,
98
+ "name" => @filter.name(span.name),
95
99
  "started_at" => iso(span.start_timestamp),
96
100
  "duration_ms" => duration_ms(span),
97
- "attributes" => span.attributes || {}
101
+ "attributes" => @filter.attributes(span.attributes)
98
102
  }
99
103
  end
100
104
 
@@ -91,17 +91,22 @@ module Flare
91
91
 
92
92
  private
93
93
 
94
- def ship(blob, deadline:, retried: false)
94
+ # Errors are contained per trace so one bad trace or one transport
95
+ # error doesn't drop the rest of the batch.
96
+ def ship(blob, deadline:, body: nil, retried: false)
95
97
  return FAILURE if blob.nil?
96
98
  return TIMEOUT if deadline.expired?
97
99
 
100
+ # Encode before checkout so a trace that can't be encoded doesn't use
101
+ # up a URL, but skip the work when there's no URL to upload to.
102
+ body ||= encode(blob) unless @pool.empty?
98
103
  entry = @pool.checkout
99
104
  if entry.nil?
100
105
  @pool_empty_count.increment
101
106
  return FAILURE
102
107
  end
103
108
 
104
- body = gzip(JSON.generate(blob.to_h))
109
+ body ||= encode(blob)
105
110
  response = transport_request(:put, entry[:put_url], body, PUT_HEADERS, timeout: deadline.remaining)
106
111
  return TIMEOUT if deadline.expired?
107
112
 
@@ -110,10 +115,16 @@ module Flare
110
115
  notify(entry[:key], deadline: deadline)
111
116
  when "403"
112
117
  # Presigned URL probably expired; try once more with the next one.
113
- retried ? record_put_failure(response) : ship(blob, deadline: deadline, retried: true)
118
+ retried ? record_put_failure(response) : ship(blob, deadline: deadline, body: body, retried: true)
114
119
  else
115
120
  record_put_failure(response)
116
121
  end
122
+ rescue StandardError => e
123
+ return TIMEOUT if deadline.expired? || e.is_a?(HttpTransport::DeadlineExceeded)
124
+
125
+ @exception_count.increment
126
+ @logger.warn("[Flare::TraceExporter] trace export raised: #{e.class}: #{e.message}")
127
+ FAILURE
117
128
  end
118
129
 
119
130
  def notify(key, deadline:)
@@ -171,6 +182,10 @@ module Flare
171
182
  FAILURE
172
183
  end
173
184
 
185
+ def encode(blob)
186
+ gzip(JSON.generate(blob.to_h))
187
+ end
188
+
174
189
  def gzip(body)
175
190
  io = StringIO.new
176
191
  gz = Zlib::GzipWriter.new(io)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/atomic/atomic_fixnum"
3
4
  require "concurrent/atomic/atomic_reference"
4
5
 
5
6
  module Flare
@@ -15,11 +16,10 @@ module Flare
15
16
  # Fork-safe: after_fork clears the pool so child processes don't reuse
16
17
  # parent URLs (each child polls its own copy from /api/rules anyway).
17
18
  class UploadUrlPool
18
- attr_reader :checkouts, :empty_count, :expired_count
19
+ attr_reader :empty_count, :expired_count
19
20
 
20
21
  def initialize
21
22
  @entries_ref = Concurrent::AtomicReference.new([].freeze)
22
- @checkouts = Concurrent::AtomicFixnum.new(0)
23
23
  @empty_count = Concurrent::AtomicFixnum.new(0)
24
24
  @expired_count = Concurrent::AtomicFixnum.new(0)
25
25
  end
@@ -47,7 +47,6 @@ module Flare
47
47
  next # try the next one
48
48
  end
49
49
 
50
- @checkouts.increment
51
50
  return candidate
52
51
  end
53
52
  end
@@ -64,18 +63,6 @@ module Flare
64
63
  @entries_ref.set([].freeze)
65
64
  end
66
65
 
67
- # Drop URLs that have already passed their expires_at. Cheap; safe to
68
- # call from RuleManager's scheduler in between polls.
69
- def sweep
70
- now = Time.now
71
- current = @entries_ref.get
72
- live = current.reject { |e| expired?(e, now) }
73
- return 0 if live.length == current.length
74
-
75
- @entries_ref.set(live.freeze)
76
- current.length - live.length
77
- end
78
-
79
66
  # Call from Flare.after_fork. Parent's URLs aren't usable from the
80
67
  # child's point of view (each child should get its own from a fresh
81
68
  # /api/rules poll), so just drop them.
data/lib/flare/utf8.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ # Span attributes carry raw request bytes (User-Agent, paths) and SQL, which
5
+ # can hold invalid UTF-8. JSON.generate raises on those, failing the whole
6
+ # export batch, so replace the bad bytes before serializing.
7
+ module UTF8
8
+ def self.scrub(value)
9
+ case value
10
+ when String
11
+ return value if value.ascii_only? || (value.valid_encoding? && value.encoding != Encoding::BINARY)
12
+
13
+ value.dup.force_encoding(Encoding::UTF_8).scrub
14
+ when Array then value.map { |v| scrub(v) }
15
+ when Hash then value.to_h { |k, v| [scrub(k), scrub(v)] }
16
+ else value
17
+ end
18
+ end
19
+ end
20
+ end
data/lib/flare/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flare
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/flare.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "flare/source_location"
10
10
  require_relative "flare/metric_key"
11
11
  require_relative "flare/metric_storage"
12
12
  require_relative "flare/metric_span_processor"
13
+ require_relative "flare/redis_statement_processor"
13
14
  require_relative "flare/metric_flusher"
14
15
  require_relative "flare/backoff_policy"
15
16
  require_relative "flare/metric_submitter"
@@ -181,7 +182,11 @@ module Flare
181
182
  require "opentelemetry-instrumentation-rack"
182
183
  require "opentelemetry-instrumentation-net_http"
183
184
  require "opentelemetry-instrumentation-active_support"
184
- require "opentelemetry/instrumentation/active_support/span_subscriber"
185
+ begin
186
+ # Internal OTel path; subscribe_to_notifications handles it moving.
187
+ require "opentelemetry/instrumentation/active_support/span_subscriber"
188
+ rescue LoadError
189
+ end
185
190
  require "opentelemetry-instrumentation-action_pack" if defined?(ActionController)
186
191
  require "opentelemetry-instrumentation-action_view" if defined?(ActionView)
187
192
  require "opentelemetry-instrumentation-active_job" if defined?(ActiveJob)
@@ -195,6 +200,9 @@ module Flare
195
200
  OpenTelemetry::SDK.configure do |c|
196
201
  c.service_name = service_name
197
202
 
203
+ # Reads Redis statements as spans start, so raw arguments don't reach a finished span.
204
+ c.add_span_processor(RedisStatementProcessor.new)
205
+
198
206
  # Spans: detailed trace data stored in SQLite
199
207
  if configuration.spans_enabled && exporter
200
208
  c.add_span_processor(span_processor)
@@ -208,11 +216,18 @@ module Flare
208
216
  "OpenTelemetry::Instrumentation::Rack" => {
209
217
  untraced_requests: ->(env) {
210
218
  request = Rack::Request.new(env)
211
- return true if request.path.start_with?("/flare")
219
+ # Skip Flare's own dashboard and assets, not app routes like /flares.
220
+ path = request.path
221
+ return true if path == "/flare" || path.start_with?("/flare/", "/flare-assets/")
212
222
 
213
223
  configuration.ignore_request.call(request)
214
224
  }
215
225
  },
226
+ # The key is what groups Redis calls. RedisStatementProcessor reduces the
227
+ # statement to the command and the key's first segment.
228
+ "OpenTelemetry::Instrumentation::Redis" => {
229
+ db_statement: :include,
230
+ },
216
231
  # Name Sidekiq job spans after the worker class (e.g. "MyWorker
217
232
  # process") instead of the upstream default of the queue name
218
233
  # ("default process"), matching how ActiveJob spans are named.
@@ -410,6 +425,10 @@ module Flare
410
425
  }.freeze
411
426
 
412
427
  def subscribe_to_notifications
428
+ unless OpenTelemetry::Instrumentation::ActiveSupport.respond_to?(:subscribe)
429
+ return warn "[Flare] OpenTelemetry::Instrumentation::ActiveSupport.subscribe not found. SQL, cache, mailer and custom notification spans are disabled. Check your opentelemetry-instrumentation-active_support version."
430
+ end
431
+
413
432
  NOTIFICATION_TRANSFORMERS.each do |pattern, transformer|
414
433
  OpenTelemetry::Instrumentation::ActiveSupport.subscribe(tracer, pattern, transformer)
415
434
  rescue
@@ -190,21 +190,6 @@ code, pre {
190
190
  opacity: 1;
191
191
  }
192
192
 
193
- .nav-item-count {
194
- margin-left: auto;
195
- font-size: 0.6875rem;
196
- font-weight: 500;
197
- color: var(--cb-text-muted);
198
- background: var(--cb-bg-muted);
199
- padding: 0.125rem 0.5rem;
200
- border-radius: 10px;
201
- }
202
-
203
- .nav-item.active .nav-item-count {
204
- background: var(--cb-text);
205
- color: white;
206
- }
207
-
208
193
  /* Sidebar Footer */
209
194
  .sidebar-footer {
210
195
  padding: 0.75rem 0.625rem;
@@ -332,21 +317,6 @@ code, pre {
332
317
  box-shadow: none;
333
318
  }
334
319
 
335
- .card-header {
336
- padding: 0.75rem 1rem;
337
- border-bottom: 1px solid var(--cb-border);
338
- display: flex;
339
- align-items: center;
340
- justify-content: space-between;
341
- background: transparent;
342
- }
343
-
344
- .card-title {
345
- font-size: 0.8125rem;
346
- font-weight: 600;
347
- color: var(--cb-text);
348
- }
349
-
350
320
  .card-body {
351
321
  padding: 0;
352
322
  }
@@ -369,6 +339,15 @@ code, pre {
369
339
  border-bottom: 1px solid var(--cb-border);
370
340
  }
371
341
 
342
+ .sort-link {
343
+ color: inherit;
344
+ }
345
+
346
+ .sort-link:hover,
347
+ .sort-link.active {
348
+ color: var(--cb-text);
349
+ }
350
+
372
351
  .data-table td {
373
352
  padding: 0.5rem 0.75rem;
374
353
  border-bottom: 1px solid var(--cb-bg-muted);
@@ -400,11 +379,6 @@ code, pre {
400
379
  letter-spacing: 0.02em;
401
380
  }
402
381
 
403
- .badge-verb {
404
- background: var(--cb-bg-muted);
405
- color: var(--cb-text-secondary);
406
- }
407
-
408
382
  .badge-get { background: rgba(107,138,154,0.15); color: #4a7a8a; }
409
383
  .badge-post { background: rgba(43,122,62,0.15); color: #1d6b2f; }
410
384
  .badge-put, .badge-patch { background: rgba(196,163,90,0.15); color: #9a7d2e; }
@@ -419,15 +393,8 @@ code, pre {
419
393
  .badge-error { background: rgba(197,48,48,0.15); color: #a53030; }
420
394
  .badge-warning { background: rgba(196,163,90,0.15); color: #9a7d2e; }
421
395
 
422
- .badge-type {
423
- font-family: var(--cb-font);
424
- font-weight: 500;
425
- }
426
-
427
- .badge-request { background: rgba(107,138,154,0.15); color: #4a7a8a; }
428
396
  .badge-job { background: rgba(138,154,107,0.15); color: #5a7a3d; }
429
397
 
430
- .badge-clue { font-family: var(--cb-font-mono); font-size: 0.6875rem; }
431
398
  .badge-sql { background: rgba(107,138,154,0.15); color: #4a7a8a; }
432
399
  .badge-cache { background: rgba(43,122,62,0.15); color: #1d6b2f; }
433
400
  .badge-view { background: rgba(196,163,90,0.15); color: #9a7d2e; }
@@ -436,7 +403,6 @@ code, pre {
436
403
  .badge-exception { background: rgba(197,48,48,0.15); color: #a53030; }
437
404
  .badge-other { background: var(--cb-bg-muted); color: var(--cb-text-secondary); }
438
405
  .badge-redis { background: rgba(197,48,48,0.12); color: #a53030; }
439
- .badge-query { background: rgba(107,138,154,0.15); color: #4a7a8a; }
440
406
 
441
407
  /* --- Search / Filters --- */
442
408
  .filters {
@@ -1037,15 +1003,6 @@ code, pre {
1037
1003
  }
1038
1004
 
1039
1005
  /* --- Code Blocks --- */
1040
- .code-path {
1041
- font-family: var(--cb-font-mono);
1042
- font-size: 0.8125rem;
1043
- color: var(--cb-text-secondary);
1044
- background: var(--cb-bg-subtle);
1045
- padding: 0.125rem 0.375rem;
1046
- border-radius: var(--cb-radius-sm);
1047
- display: inline-block;
1048
- }
1049
1006
 
1050
1007
  pre {
1051
1008
  background: var(--cb-text);
@@ -1168,14 +1125,7 @@ pre {
1168
1125
  border: 1px solid rgba(197,48,48,0.2);
1169
1126
  }
1170
1127
 
1171
- .alert-success {
1172
- background: rgba(43,122,62,0.1);
1173
- color: var(--cb-success);
1174
- border: 1px solid rgba(43,122,62,0.2);
1175
- }
1176
-
1177
1128
  /* --- Muted --- */
1178
- .muted { color: var(--cb-text-muted); }
1179
1129
 
1180
1130
  /* --- Responsive --- */
1181
1131
  @media (max-width: 1024px) {
@@ -1186,7 +1136,7 @@ pre {
1186
1136
  padding: 1rem 0.75rem;
1187
1137
  justify-content: center;
1188
1138
  }
1189
- .logo-text, .nav-section-title, .nav-item span, .nav-item-count, .btn-clear span {
1139
+ .logo-text, .nav-section-title, .nav-item span, .btn-clear span {
1190
1140
  display: none;
1191
1141
  }
1192
1142
  .nav-item {