tracekit 0.2.3 → 0.2.4
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/lib/tracekit/evaluator.rb +604 -0
- data/lib/tracekit/snapshots/client.rb +119 -46
- data/lib/tracekit/snapshots/models.rb +6 -0
- data/lib/tracekit/version.rb +1 -1
- data/lib/tracekit.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9133bb6f1112ae31a4732cf52a12d209fa908e6f0acb5af92d3bf566ecc99ff5
|
|
4
|
+
data.tar.gz: dd8eeb7476eef7bc591833194b8805c9f0c74f8c1b8e389a4b34834ceea5c201
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10f4ff9f1123866d67485d7b02a1224a22abc9f3e05814064d8057103172c7a13a7a527a8626b612a622df3feed3f9ec0ddb632550d669a1a62f46a18354c86f
|
|
7
|
+
data.tar.gz: 3b9c8a7b79eb316ef4322b1b54deba4bc9e6b3538ff2c3fdefb6b4cfff5b394e6003d4dcbe225d837f2da5fa6f246044e1c6052d539b6e7a1063317c71b639a8
|
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracekit
|
|
4
|
+
# Expression evaluator for the TraceKit portable expression subset.
|
|
5
|
+
# Evaluates breakpoint conditions locally to avoid server round-trips.
|
|
6
|
+
#
|
|
7
|
+
# Uses a custom recursive-descent parser -- never eval()/instance_eval.
|
|
8
|
+
# Supports: comparison, logical, arithmetic, string concat, property access,
|
|
9
|
+
# bracket notation, membership (in), null safety, and grouping.
|
|
10
|
+
module Evaluator
|
|
11
|
+
class UnsupportedExpressionError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Returns true if the expression can be evaluated locally by the SDK.
|
|
14
|
+
# Returns false for expressions containing function calls, regex operators,
|
|
15
|
+
# assignment, array indexing, ternary, range, template literals, or bitwise operators.
|
|
16
|
+
def self.sdk_evaluable?(expression)
|
|
17
|
+
return true if expression.nil? || expression.strip.empty?
|
|
18
|
+
|
|
19
|
+
# Function calls: word followed by opening paren
|
|
20
|
+
return false if expression.match?(/\b[a-zA-Z_]\w*\s*\(/)
|
|
21
|
+
|
|
22
|
+
# Regex match keyword
|
|
23
|
+
return false if expression.match?(/\bmatches\b/)
|
|
24
|
+
|
|
25
|
+
# Regex operator =~
|
|
26
|
+
return false if expression.include?("=~")
|
|
27
|
+
|
|
28
|
+
# Bitwise NOT ~ (but not inside =~, already handled above)
|
|
29
|
+
expression.each_char.with_index do |ch, i|
|
|
30
|
+
if ch == "~" && (i == 0 || expression[i - 1] != "=")
|
|
31
|
+
return false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Bitwise AND: single & not part of &&
|
|
36
|
+
i = 0
|
|
37
|
+
while i < expression.length
|
|
38
|
+
if expression[i] == "&"
|
|
39
|
+
if i + 1 < expression.length && expression[i + 1] == "&"
|
|
40
|
+
i += 2
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
return false
|
|
44
|
+
end
|
|
45
|
+
i += 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Bitwise OR: single | not part of ||
|
|
49
|
+
i = 0
|
|
50
|
+
while i < expression.length
|
|
51
|
+
if expression[i] == "|"
|
|
52
|
+
if i + 1 < expression.length && expression[i + 1] == "|"
|
|
53
|
+
i += 2
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
return false
|
|
57
|
+
end
|
|
58
|
+
i += 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Bit shift
|
|
62
|
+
return false if expression.include?("<<") || expression.include?(">>")
|
|
63
|
+
|
|
64
|
+
# Template literals
|
|
65
|
+
return false if expression.include?("${")
|
|
66
|
+
|
|
67
|
+
# Range operator
|
|
68
|
+
return false if expression.include?("..")
|
|
69
|
+
|
|
70
|
+
# Ternary
|
|
71
|
+
return false if expression.include?("?")
|
|
72
|
+
|
|
73
|
+
# Array indexing [N]
|
|
74
|
+
return false if expression.match?(/\[\d/)
|
|
75
|
+
|
|
76
|
+
# Compound assignment
|
|
77
|
+
return false if expression.match?(/[+\-*\/]=/)
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Evaluates an expression string against the given environment
|
|
83
|
+
# and returns a boolean result. Empty expressions return true.
|
|
84
|
+
# Raises UnsupportedExpressionError for server-only expressions.
|
|
85
|
+
def self.evaluate_condition(expression, env)
|
|
86
|
+
return true if expression.nil? || expression.strip.empty?
|
|
87
|
+
|
|
88
|
+
unless sdk_evaluable?(expression)
|
|
89
|
+
raise UnsupportedExpressionError, "unsupported expression: requires server-side evaluation"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result = evaluate_expression(expression, env)
|
|
93
|
+
|
|
94
|
+
case result
|
|
95
|
+
when true, false
|
|
96
|
+
result
|
|
97
|
+
when nil
|
|
98
|
+
false
|
|
99
|
+
else
|
|
100
|
+
# Non-boolean result from a condition -- treat as truthy
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Evaluates an expression and returns the raw result value.
|
|
106
|
+
# Raises UnsupportedExpressionError for server-only expressions.
|
|
107
|
+
def self.evaluate_expression(expression, env)
|
|
108
|
+
return nil if expression.nil? || expression.strip.empty?
|
|
109
|
+
|
|
110
|
+
unless sdk_evaluable?(expression)
|
|
111
|
+
raise UnsupportedExpressionError, "unsupported expression: requires server-side evaluation"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
tokens = Lexer.tokenize(expression)
|
|
115
|
+
parser = Parser.new(tokens, env)
|
|
116
|
+
parser.parse_expression
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Evaluates multiple expressions against the given environment.
|
|
120
|
+
# Results are keyed by expression string. On error, nil is stored.
|
|
121
|
+
def self.evaluate_expressions(expressions, env)
|
|
122
|
+
results = {}
|
|
123
|
+
expressions.each do |expr|
|
|
124
|
+
results[expr] = evaluate_expression(expr, env)
|
|
125
|
+
rescue StandardError
|
|
126
|
+
results[expr] = nil
|
|
127
|
+
end
|
|
128
|
+
results
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Token types for the lexer
|
|
132
|
+
module TokenType
|
|
133
|
+
NUMBER = :number
|
|
134
|
+
STRING = :string
|
|
135
|
+
BOOL = :bool
|
|
136
|
+
NIL = :nil
|
|
137
|
+
IDENT = :ident
|
|
138
|
+
DOT = :dot
|
|
139
|
+
LBRACKET = :lbracket
|
|
140
|
+
RBRACKET = :rbracket
|
|
141
|
+
LPAREN = :lparen
|
|
142
|
+
RPAREN = :rparen
|
|
143
|
+
PLUS = :plus
|
|
144
|
+
MINUS = :minus
|
|
145
|
+
STAR = :star
|
|
146
|
+
SLASH = :slash
|
|
147
|
+
EQ = :eq
|
|
148
|
+
NEQ = :neq
|
|
149
|
+
LT = :lt
|
|
150
|
+
GT = :gt
|
|
151
|
+
LTE = :lte
|
|
152
|
+
GTE = :gte
|
|
153
|
+
AND = :and
|
|
154
|
+
OR = :or
|
|
155
|
+
NOT = :not
|
|
156
|
+
IN = :in
|
|
157
|
+
EOF = :eof
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Token = Struct.new(:type, :value, keyword_init: true)
|
|
161
|
+
|
|
162
|
+
# Lexer: converts expression string into tokens.
|
|
163
|
+
module Lexer
|
|
164
|
+
def self.tokenize(input)
|
|
165
|
+
tokens = []
|
|
166
|
+
i = 0
|
|
167
|
+
while i < input.length
|
|
168
|
+
ch = input[i]
|
|
169
|
+
|
|
170
|
+
# Skip whitespace
|
|
171
|
+
if ch =~ /\s/
|
|
172
|
+
i += 1
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Two-character operators
|
|
177
|
+
if i + 1 < input.length
|
|
178
|
+
two = input[i, 2]
|
|
179
|
+
case two
|
|
180
|
+
when "=="
|
|
181
|
+
tokens << Token.new(type: TokenType::EQ, value: "==")
|
|
182
|
+
i += 2
|
|
183
|
+
next
|
|
184
|
+
when "!="
|
|
185
|
+
tokens << Token.new(type: TokenType::NEQ, value: "!=")
|
|
186
|
+
i += 2
|
|
187
|
+
next
|
|
188
|
+
when "<="
|
|
189
|
+
tokens << Token.new(type: TokenType::LTE, value: "<=")
|
|
190
|
+
i += 2
|
|
191
|
+
next
|
|
192
|
+
when ">="
|
|
193
|
+
tokens << Token.new(type: TokenType::GTE, value: ">=")
|
|
194
|
+
i += 2
|
|
195
|
+
next
|
|
196
|
+
when "&&"
|
|
197
|
+
tokens << Token.new(type: TokenType::AND, value: "&&")
|
|
198
|
+
i += 2
|
|
199
|
+
next
|
|
200
|
+
when "||"
|
|
201
|
+
tokens << Token.new(type: TokenType::OR, value: "||")
|
|
202
|
+
i += 2
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Single-character tokens
|
|
208
|
+
case ch
|
|
209
|
+
when "."
|
|
210
|
+
tokens << Token.new(type: TokenType::DOT, value: ".")
|
|
211
|
+
i += 1
|
|
212
|
+
next
|
|
213
|
+
when "["
|
|
214
|
+
tokens << Token.new(type: TokenType::LBRACKET, value: "[")
|
|
215
|
+
i += 1
|
|
216
|
+
next
|
|
217
|
+
when "]"
|
|
218
|
+
tokens << Token.new(type: TokenType::RBRACKET, value: "]")
|
|
219
|
+
i += 1
|
|
220
|
+
next
|
|
221
|
+
when "("
|
|
222
|
+
tokens << Token.new(type: TokenType::LPAREN, value: "(")
|
|
223
|
+
i += 1
|
|
224
|
+
next
|
|
225
|
+
when ")"
|
|
226
|
+
tokens << Token.new(type: TokenType::RPAREN, value: ")")
|
|
227
|
+
i += 1
|
|
228
|
+
next
|
|
229
|
+
when "+"
|
|
230
|
+
tokens << Token.new(type: TokenType::PLUS, value: "+")
|
|
231
|
+
i += 1
|
|
232
|
+
next
|
|
233
|
+
when "-"
|
|
234
|
+
# Check if this is a negative number (unary minus before digits)
|
|
235
|
+
if (tokens.empty? || [:plus, :minus, :star, :slash, :eq, :neq, :lt, :gt, :lte, :gte, :and, :or, :not, :lparen].include?(tokens.last&.type)) &&
|
|
236
|
+
i + 1 < input.length && input[i + 1] =~ /\d/
|
|
237
|
+
# Parse as negative number
|
|
238
|
+
start = i
|
|
239
|
+
i += 1
|
|
240
|
+
i += 1 while i < input.length && (input[i] =~ /\d/ || input[i] == ".")
|
|
241
|
+
num_str = input[start...i]
|
|
242
|
+
value = num_str.include?(".") ? num_str.to_f : num_str.to_i
|
|
243
|
+
tokens << Token.new(type: TokenType::NUMBER, value: value)
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
tokens << Token.new(type: TokenType::MINUS, value: "-")
|
|
247
|
+
i += 1
|
|
248
|
+
next
|
|
249
|
+
when "*"
|
|
250
|
+
tokens << Token.new(type: TokenType::STAR, value: "*")
|
|
251
|
+
i += 1
|
|
252
|
+
next
|
|
253
|
+
when "/"
|
|
254
|
+
tokens << Token.new(type: TokenType::SLASH, value: "/")
|
|
255
|
+
i += 1
|
|
256
|
+
next
|
|
257
|
+
when "<"
|
|
258
|
+
tokens << Token.new(type: TokenType::LT, value: "<")
|
|
259
|
+
i += 1
|
|
260
|
+
next
|
|
261
|
+
when ">"
|
|
262
|
+
tokens << Token.new(type: TokenType::GT, value: ">")
|
|
263
|
+
i += 1
|
|
264
|
+
next
|
|
265
|
+
when "!"
|
|
266
|
+
tokens << Token.new(type: TokenType::NOT, value: "!")
|
|
267
|
+
i += 1
|
|
268
|
+
next
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# String literals (double or single quoted)
|
|
272
|
+
if ch == '"' || ch == "'"
|
|
273
|
+
quote = ch
|
|
274
|
+
i += 1
|
|
275
|
+
start = i
|
|
276
|
+
while i < input.length && input[i] != quote
|
|
277
|
+
i += 1 if input[i] == "\\" # skip escaped char
|
|
278
|
+
i += 1
|
|
279
|
+
end
|
|
280
|
+
tokens << Token.new(type: TokenType::STRING, value: input[start...i])
|
|
281
|
+
i += 1 # skip closing quote
|
|
282
|
+
next
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Numbers
|
|
286
|
+
if ch =~ /\d/
|
|
287
|
+
start = i
|
|
288
|
+
i += 1 while i < input.length && (input[i] =~ /\d/ || input[i] == ".")
|
|
289
|
+
num_str = input[start...i]
|
|
290
|
+
value = num_str.include?(".") ? num_str.to_f : num_str.to_i
|
|
291
|
+
tokens << Token.new(type: TokenType::NUMBER, value: value)
|
|
292
|
+
next
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Identifiers and keywords
|
|
296
|
+
if ch =~ /[a-zA-Z_]/
|
|
297
|
+
start = i
|
|
298
|
+
i += 1 while i < input.length && input[i] =~ /[a-zA-Z0-9_]/
|
|
299
|
+
word = input[start...i]
|
|
300
|
+
case word
|
|
301
|
+
when "true"
|
|
302
|
+
tokens << Token.new(type: TokenType::BOOL, value: true)
|
|
303
|
+
when "false"
|
|
304
|
+
tokens << Token.new(type: TokenType::BOOL, value: false)
|
|
305
|
+
when "nil", "null", "None"
|
|
306
|
+
tokens << Token.new(type: TokenType::NIL, value: nil)
|
|
307
|
+
when "in"
|
|
308
|
+
tokens << Token.new(type: TokenType::IN, value: "in")
|
|
309
|
+
else
|
|
310
|
+
tokens << Token.new(type: TokenType::IDENT, value: word)
|
|
311
|
+
end
|
|
312
|
+
next
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
raise "unexpected character: #{ch}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
tokens << Token.new(type: TokenType::EOF, value: nil)
|
|
319
|
+
tokens
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Recursive-descent parser and evaluator.
|
|
324
|
+
# Operator precedence (lowest to highest):
|
|
325
|
+
# || -> && -> == != -> < > <= >= -> + - -> * / -> ! (unary) -> primary
|
|
326
|
+
class Parser
|
|
327
|
+
def initialize(tokens, env)
|
|
328
|
+
@tokens = tokens
|
|
329
|
+
@env = env
|
|
330
|
+
@pos = 0
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def parse_expression
|
|
334
|
+
parse_or
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
private
|
|
338
|
+
|
|
339
|
+
def current
|
|
340
|
+
@tokens[@pos]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def advance
|
|
344
|
+
t = @tokens[@pos]
|
|
345
|
+
@pos += 1
|
|
346
|
+
t
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def match(*types)
|
|
350
|
+
if types.include?(current.type)
|
|
351
|
+
advance
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def expect(type)
|
|
356
|
+
if current.type == type
|
|
357
|
+
advance
|
|
358
|
+
else
|
|
359
|
+
raise "expected #{type}, got #{current.type} (#{current.value})"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# OR: expr || expr
|
|
364
|
+
def parse_or
|
|
365
|
+
left = parse_and
|
|
366
|
+
while current.type == TokenType::OR
|
|
367
|
+
advance
|
|
368
|
+
right = parse_and
|
|
369
|
+
left = left || right
|
|
370
|
+
end
|
|
371
|
+
left
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# AND: expr && expr
|
|
375
|
+
def parse_and
|
|
376
|
+
left = parse_equality
|
|
377
|
+
while current.type == TokenType::AND
|
|
378
|
+
advance
|
|
379
|
+
right = parse_equality
|
|
380
|
+
left = left && right
|
|
381
|
+
end
|
|
382
|
+
left
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Equality: == !=
|
|
386
|
+
def parse_equality
|
|
387
|
+
left = parse_comparison
|
|
388
|
+
while (op = match(TokenType::EQ, TokenType::NEQ))
|
|
389
|
+
right = parse_comparison
|
|
390
|
+
left = case op.type
|
|
391
|
+
when TokenType::EQ then safe_equal?(left, right)
|
|
392
|
+
when TokenType::NEQ then !safe_equal?(left, right)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
left
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Comparison: < > <= >=
|
|
399
|
+
def parse_comparison
|
|
400
|
+
left = parse_membership
|
|
401
|
+
while (op = match(TokenType::LT, TokenType::GT, TokenType::LTE, TokenType::GTE))
|
|
402
|
+
right = parse_membership
|
|
403
|
+
left = safe_compare(op.type, left, right)
|
|
404
|
+
end
|
|
405
|
+
left
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Membership: "key" in map
|
|
409
|
+
def parse_membership
|
|
410
|
+
left = parse_addition
|
|
411
|
+
if current.type == TokenType::IN
|
|
412
|
+
advance
|
|
413
|
+
right = parse_addition
|
|
414
|
+
return membership_check(left, right)
|
|
415
|
+
end
|
|
416
|
+
left
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Addition/subtraction/string concatenation
|
|
420
|
+
def parse_addition
|
|
421
|
+
left = parse_multiplication
|
|
422
|
+
while (op = match(TokenType::PLUS, TokenType::MINUS))
|
|
423
|
+
right = parse_multiplication
|
|
424
|
+
if op.type == TokenType::PLUS
|
|
425
|
+
if left.is_a?(String) || right.is_a?(String)
|
|
426
|
+
left = "#{left}#{right}"
|
|
427
|
+
else
|
|
428
|
+
left = numeric_val(left) + numeric_val(right)
|
|
429
|
+
end
|
|
430
|
+
else
|
|
431
|
+
left = numeric_val(left) - numeric_val(right)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
left
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Multiplication/division
|
|
438
|
+
def parse_multiplication
|
|
439
|
+
left = parse_unary
|
|
440
|
+
while (op = match(TokenType::STAR, TokenType::SLASH))
|
|
441
|
+
right = parse_unary
|
|
442
|
+
if op.type == TokenType::STAR
|
|
443
|
+
left = numeric_val(left) * numeric_val(right)
|
|
444
|
+
else
|
|
445
|
+
divisor = numeric_val(right)
|
|
446
|
+
return nil if divisor == 0
|
|
447
|
+
left = numeric_val(left) / divisor
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
left
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Unary: ! (NOT)
|
|
454
|
+
def parse_unary
|
|
455
|
+
if current.type == TokenType::NOT
|
|
456
|
+
advance
|
|
457
|
+
val = parse_unary
|
|
458
|
+
return !val
|
|
459
|
+
end
|
|
460
|
+
parse_primary
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Primary: literals, identifiers (with property access), grouping
|
|
464
|
+
def parse_primary
|
|
465
|
+
tok = current
|
|
466
|
+
|
|
467
|
+
case tok.type
|
|
468
|
+
when TokenType::NUMBER
|
|
469
|
+
advance
|
|
470
|
+
resolve_postfix(tok.value)
|
|
471
|
+
when TokenType::STRING
|
|
472
|
+
advance
|
|
473
|
+
resolve_postfix(tok.value)
|
|
474
|
+
when TokenType::BOOL
|
|
475
|
+
advance
|
|
476
|
+
resolve_postfix(tok.value)
|
|
477
|
+
when TokenType::NIL
|
|
478
|
+
advance
|
|
479
|
+
resolve_postfix(nil)
|
|
480
|
+
when TokenType::IDENT
|
|
481
|
+
advance
|
|
482
|
+
value = resolve_identifier(tok.value)
|
|
483
|
+
resolve_postfix(value)
|
|
484
|
+
when TokenType::LPAREN
|
|
485
|
+
advance
|
|
486
|
+
result = parse_expression
|
|
487
|
+
expect(TokenType::RPAREN)
|
|
488
|
+
resolve_postfix(result)
|
|
489
|
+
else
|
|
490
|
+
raise "unexpected token: #{tok.type} (#{tok.value})"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Handle dot notation and bracket notation after a value
|
|
495
|
+
def resolve_postfix(value)
|
|
496
|
+
loop do
|
|
497
|
+
if current.type == TokenType::DOT
|
|
498
|
+
advance
|
|
499
|
+
if current.type == TokenType::IDENT
|
|
500
|
+
key = advance.value
|
|
501
|
+
value = safe_access(value, key)
|
|
502
|
+
else
|
|
503
|
+
raise "expected identifier after dot"
|
|
504
|
+
end
|
|
505
|
+
elsif current.type == TokenType::LBRACKET
|
|
506
|
+
advance
|
|
507
|
+
key = parse_expression
|
|
508
|
+
expect(TokenType::RBRACKET)
|
|
509
|
+
value = safe_access(value, key.to_s)
|
|
510
|
+
else
|
|
511
|
+
break
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
value
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Resolve a top-level identifier from the environment
|
|
518
|
+
def resolve_identifier(name)
|
|
519
|
+
if @env.key?(name)
|
|
520
|
+
@env[name]
|
|
521
|
+
elsif @env.key?(name.to_sym)
|
|
522
|
+
@env[name.to_sym]
|
|
523
|
+
else
|
|
524
|
+
nil
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Null-safe property access: accessing a key on nil returns nil
|
|
529
|
+
def safe_access(obj, key)
|
|
530
|
+
return nil if obj.nil?
|
|
531
|
+
|
|
532
|
+
if obj.is_a?(Hash)
|
|
533
|
+
if obj.key?(key)
|
|
534
|
+
obj[key]
|
|
535
|
+
elsif obj.key?(key.to_sym)
|
|
536
|
+
obj[key.to_sym]
|
|
537
|
+
else
|
|
538
|
+
nil
|
|
539
|
+
end
|
|
540
|
+
else
|
|
541
|
+
nil
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Safe equality with type coercion rules:
|
|
546
|
+
# - Integer/Float promotion allowed
|
|
547
|
+
# - No other implicit coercion
|
|
548
|
+
def safe_equal?(a, b)
|
|
549
|
+
# nil handling
|
|
550
|
+
return true if a.nil? && b.nil?
|
|
551
|
+
return false if a.nil? || b.nil?
|
|
552
|
+
|
|
553
|
+
# Integer/Float promotion
|
|
554
|
+
if numeric?(a) && numeric?(b)
|
|
555
|
+
return a.to_f == b.to_f
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
a == b
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Safe comparison for < > <= >=
|
|
562
|
+
# Returns false for incompatible types
|
|
563
|
+
def safe_compare(op, a, b)
|
|
564
|
+
return false if a.nil? || b.nil?
|
|
565
|
+
|
|
566
|
+
# Both numeric
|
|
567
|
+
if numeric?(a) && numeric?(b)
|
|
568
|
+
a_val = a.to_f
|
|
569
|
+
b_val = b.to_f
|
|
570
|
+
case op
|
|
571
|
+
when TokenType::LT then a_val < b_val
|
|
572
|
+
when TokenType::GT then a_val > b_val
|
|
573
|
+
when TokenType::LTE then a_val <= b_val
|
|
574
|
+
when TokenType::GTE then a_val >= b_val
|
|
575
|
+
end
|
|
576
|
+
elsif a.is_a?(String) && b.is_a?(String)
|
|
577
|
+
case op
|
|
578
|
+
when TokenType::LT then a < b
|
|
579
|
+
when TokenType::GT then a > b
|
|
580
|
+
when TokenType::LTE then a <= b
|
|
581
|
+
when TokenType::GTE then a >= b
|
|
582
|
+
end
|
|
583
|
+
else
|
|
584
|
+
false
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Check if "key" in map
|
|
589
|
+
def membership_check(key, map)
|
|
590
|
+
return false unless map.is_a?(Hash)
|
|
591
|
+
map.key?(key) || map.key?(key.to_s) || map.key?(key.to_sym)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def numeric?(val)
|
|
595
|
+
val.is_a?(Integer) || val.is_a?(Float)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def numeric_val(val)
|
|
599
|
+
return 0 if val.nil?
|
|
600
|
+
val
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|
|
@@ -91,9 +91,31 @@ module Tracekit
|
|
|
91
91
|
return if breakpoint.expire_at && Time.now > breakpoint.expire_at
|
|
92
92
|
return if breakpoint.max_captures > 0 && breakpoint.capture_count >= breakpoint.max_captures
|
|
93
93
|
|
|
94
|
-
#
|
|
95
|
-
if
|
|
96
|
-
|
|
94
|
+
# Evaluate breakpoint condition locally for sdk-evaluable expressions
|
|
95
|
+
if breakpoint.condition && !breakpoint.condition.empty? && breakpoint.condition_eval == "sdk-evaluable"
|
|
96
|
+
begin
|
|
97
|
+
result = Tracekit::Evaluator.evaluate_condition(breakpoint.condition, variables)
|
|
98
|
+
return unless result # Condition false, skip capture
|
|
99
|
+
rescue Tracekit::Evaluator::UnsupportedExpressionError
|
|
100
|
+
# Classified as sdk-evaluable but failed locally, fall through to server
|
|
101
|
+
warn "TraceKit: expression classified as sdk-evaluable but failed locally, falling back to server" if ENV["DEBUG"]
|
|
102
|
+
rescue => e
|
|
103
|
+
# Other evaluation error, log and fall through to server
|
|
104
|
+
warn "TraceKit: condition evaluation error, falling back to server: #{e.message}" if ENV["DEBUG"]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Logpoint mode: capture only expression results, skip locals/stack/request
|
|
109
|
+
if breakpoint.mode == "logpoint"
|
|
110
|
+
snapshot = build_logpoint_snapshot(breakpoint, file_path, line_number, function_name, label, variables)
|
|
111
|
+
Thread.new { submit_snapshot_with_payload_limit(snapshot, breakpoint.max_payload_bytes) }
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Apply per-breakpoint capture depth limit (with SDK-level fallback)
|
|
116
|
+
effective_depth = breakpoint.max_depth || @capture_depth
|
|
117
|
+
if effective_depth && effective_depth > 0
|
|
118
|
+
variables = limit_depth(variables, 0, effective_depth)
|
|
97
119
|
end
|
|
98
120
|
|
|
99
121
|
# Scan for security issues
|
|
@@ -104,14 +126,23 @@ module Tracekit
|
|
|
104
126
|
span_id = nil
|
|
105
127
|
if defined?(OpenTelemetry::Trace)
|
|
106
128
|
span = OpenTelemetry::Trace.current_span
|
|
107
|
-
if span && span.context.valid?
|
|
108
|
-
|
|
109
|
-
|
|
129
|
+
if span && span.context.valid?
|
|
130
|
+
sampled = begin
|
|
131
|
+
span.context.trace_flags.sampled?
|
|
132
|
+
rescue NoMethodError
|
|
133
|
+
# Fallback for older OTel versions
|
|
134
|
+
(span.context.trace_flags.to_i & 0x01) != 0 rescue false
|
|
135
|
+
end
|
|
136
|
+
if sampled
|
|
137
|
+
trace_id = span.context.hex_trace_id
|
|
138
|
+
span_id = span.context.hex_span_id
|
|
139
|
+
end
|
|
110
140
|
end
|
|
111
141
|
end
|
|
112
142
|
|
|
113
|
-
# Get stack trace
|
|
114
|
-
|
|
143
|
+
# Get stack trace with dynamic depth from per-breakpoint config
|
|
144
|
+
effective_stack_depth = breakpoint.stack_depth || 50
|
|
145
|
+
stack_trace = caller(1, effective_stack_depth).join("\n")
|
|
115
146
|
|
|
116
147
|
snapshot = Snapshot.new(
|
|
117
148
|
breakpoint_id: breakpoint.id,
|
|
@@ -128,24 +159,9 @@ module Tracekit
|
|
|
128
159
|
captured_at: Time.now.utc.iso8601
|
|
129
160
|
)
|
|
130
161
|
|
|
131
|
-
# Apply
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
snapshot = Snapshot.new(
|
|
135
|
-
breakpoint_id: breakpoint.id,
|
|
136
|
-
service_name: @service_name,
|
|
137
|
-
file_path: file_path,
|
|
138
|
-
function_name: function_name,
|
|
139
|
-
label: label,
|
|
140
|
-
line_number: line_number,
|
|
141
|
-
variables: { "_truncated" => true, "_payload_size" => serialized.bytesize, "_max_payload" => @max_payload },
|
|
142
|
-
security_flags: [],
|
|
143
|
-
stack_trace: stack_trace,
|
|
144
|
-
trace_id: trace_id,
|
|
145
|
-
span_id: span_id,
|
|
146
|
-
captured_at: Time.now.utc.iso8601
|
|
147
|
-
)
|
|
148
|
-
end
|
|
162
|
+
# Apply per-breakpoint max payload limit (with SDK-level fallback)
|
|
163
|
+
effective_max_payload = breakpoint.max_payload_bytes || @max_payload
|
|
164
|
+
submit_snapshot_with_payload_limit(snapshot, effective_max_payload)
|
|
149
165
|
|
|
150
166
|
# Submit asynchronously (with optional timeout)
|
|
151
167
|
if @capture_timeout && @capture_timeout > 0
|
|
@@ -169,24 +185,77 @@ module Tracekit
|
|
|
169
185
|
|
|
170
186
|
private
|
|
171
187
|
|
|
172
|
-
# Limit variable nesting depth (opt-in)
|
|
173
|
-
def limit_depth(data, current_depth)
|
|
174
|
-
|
|
188
|
+
# Limit variable nesting depth (opt-in, supports per-breakpoint override)
|
|
189
|
+
def limit_depth(data, current_depth, max_depth = nil)
|
|
190
|
+
effective_depth = max_depth || @capture_depth
|
|
191
|
+
return { "_truncated" => true, "_depth" => current_depth } if current_depth >= effective_depth
|
|
175
192
|
|
|
176
193
|
case data
|
|
177
194
|
when Hash
|
|
178
195
|
result = {}
|
|
179
196
|
data.each do |k, v|
|
|
180
|
-
result[k] = limit_depth(v, current_depth + 1)
|
|
197
|
+
result[k] = limit_depth(v, current_depth + 1, effective_depth)
|
|
181
198
|
end
|
|
182
199
|
result
|
|
183
200
|
when Array
|
|
184
|
-
data.map { |item| limit_depth(item, current_depth + 1) }
|
|
201
|
+
data.map { |item| limit_depth(item, current_depth + 1, effective_depth) }
|
|
185
202
|
else
|
|
186
203
|
data
|
|
187
204
|
end
|
|
188
205
|
end
|
|
189
206
|
|
|
207
|
+
# Build a logpoint snapshot: expression results only, no locals/stack
|
|
208
|
+
def build_logpoint_snapshot(breakpoint, file_path, line_number, function_name, label, variables)
|
|
209
|
+
expression_results = {}
|
|
210
|
+
if breakpoint.capture_expressions && !breakpoint.capture_expressions.empty?
|
|
211
|
+
expression_results = Tracekit::Evaluator.evaluate_expressions(
|
|
212
|
+
breakpoint.capture_expressions, variables
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
Snapshot.new(
|
|
217
|
+
breakpoint_id: breakpoint.id,
|
|
218
|
+
service_name: @service_name,
|
|
219
|
+
file_path: file_path,
|
|
220
|
+
function_name: function_name,
|
|
221
|
+
label: label,
|
|
222
|
+
line_number: line_number,
|
|
223
|
+
variables: {},
|
|
224
|
+
security_flags: [],
|
|
225
|
+
stack_trace: "",
|
|
226
|
+
trace_id: nil,
|
|
227
|
+
span_id: nil,
|
|
228
|
+
captured_at: Time.now.utc.iso8601,
|
|
229
|
+
expression_results: expression_results,
|
|
230
|
+
mode: "logpoint"
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Submit snapshot with payload limit check
|
|
235
|
+
def submit_snapshot_with_payload_limit(snapshot, max_payload_bytes)
|
|
236
|
+
effective_limit = max_payload_bytes || @max_payload
|
|
237
|
+
if effective_limit && effective_limit > 0
|
|
238
|
+
serialized = JSON.generate(snapshot.to_h)
|
|
239
|
+
if serialized.bytesize > effective_limit
|
|
240
|
+
snapshot = Snapshot.new(
|
|
241
|
+
breakpoint_id: snapshot.breakpoint_id,
|
|
242
|
+
service_name: snapshot.service_name,
|
|
243
|
+
file_path: snapshot.file_path,
|
|
244
|
+
function_name: snapshot.function_name,
|
|
245
|
+
label: snapshot.label,
|
|
246
|
+
line_number: snapshot.line_number,
|
|
247
|
+
variables: { "_truncated" => true, "_payload_size" => serialized.bytesize, "_max_payload" => effective_limit },
|
|
248
|
+
security_flags: [],
|
|
249
|
+
stack_trace: snapshot.stack_trace,
|
|
250
|
+
trace_id: snapshot.trace_id,
|
|
251
|
+
span_id: snapshot.span_id,
|
|
252
|
+
captured_at: snapshot.captured_at
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
Thread.new { submit_snapshot(snapshot) }
|
|
257
|
+
end
|
|
258
|
+
|
|
190
259
|
def fetch_active_breakpoints
|
|
191
260
|
url = "#{@base_url}/sdk/snapshots/active/#{@service_name}"
|
|
192
261
|
uri = URI(url)
|
|
@@ -237,17 +306,7 @@ module Tracekit
|
|
|
237
306
|
@breakpoints_cache.clear
|
|
238
307
|
|
|
239
308
|
breakpoints.each do |bp_data|
|
|
240
|
-
bp =
|
|
241
|
-
id: bp_data[:id],
|
|
242
|
-
file_path: bp_data[:file_path],
|
|
243
|
-
line_number: bp_data[:line_number],
|
|
244
|
-
function_name: bp_data[:function_name],
|
|
245
|
-
label: bp_data[:label],
|
|
246
|
-
enabled: bp_data[:enabled],
|
|
247
|
-
max_captures: bp_data[:max_captures] || 0,
|
|
248
|
-
capture_count: bp_data[:capture_count] || 0,
|
|
249
|
-
expire_at: bp_data[:expire_at] ? Time.parse(bp_data[:expire_at]) : nil
|
|
250
|
-
)
|
|
309
|
+
bp = build_breakpoint_config(bp_data)
|
|
251
310
|
|
|
252
311
|
# Key by function + label
|
|
253
312
|
if bp.label && bp.function_name
|
|
@@ -303,6 +362,7 @@ module Tracekit
|
|
|
303
362
|
end
|
|
304
363
|
|
|
305
364
|
def submit_snapshot(snapshot)
|
|
365
|
+
# Circuit breaker check
|
|
306
366
|
# Circuit breaker check
|
|
307
367
|
return unless circuit_breaker_should_allow?
|
|
308
368
|
|
|
@@ -455,9 +515,9 @@ module Tracekit
|
|
|
455
515
|
warn "TraceKit: SSE event handling error: #{e.message}" if ENV["DEBUG"]
|
|
456
516
|
end
|
|
457
517
|
|
|
458
|
-
#
|
|
459
|
-
def
|
|
460
|
-
|
|
518
|
+
# Build a BreakpointConfig from parsed payload data
|
|
519
|
+
def build_breakpoint_config(bp_data)
|
|
520
|
+
BreakpointConfig.new(
|
|
461
521
|
id: bp_data[:id],
|
|
462
522
|
file_path: bp_data[:file_path],
|
|
463
523
|
line_number: bp_data[:line_number],
|
|
@@ -466,8 +526,21 @@ module Tracekit
|
|
|
466
526
|
enabled: bp_data[:enabled],
|
|
467
527
|
max_captures: bp_data[:max_captures] || 0,
|
|
468
528
|
capture_count: bp_data[:capture_count] || 0,
|
|
469
|
-
expire_at: bp_data[:expire_at] ? Time.parse(bp_data[:expire_at]) : nil
|
|
529
|
+
expire_at: bp_data[:expire_at] ? Time.parse(bp_data[:expire_at]) : nil,
|
|
530
|
+
condition: bp_data[:condition],
|
|
531
|
+
condition_eval: bp_data[:condition_eval],
|
|
532
|
+
mode: bp_data[:mode],
|
|
533
|
+
stack_depth: bp_data[:stack_depth],
|
|
534
|
+
max_depth: bp_data[:max_depth],
|
|
535
|
+
max_payload_bytes: bp_data[:max_payload_bytes],
|
|
536
|
+
capture_expressions: bp_data[:capture_expressions],
|
|
537
|
+
idle_timeout_hours: bp_data[:idle_timeout_hours]
|
|
470
538
|
)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Upsert a single breakpoint into the cache
|
|
542
|
+
def upsert_breakpoint(bp_data)
|
|
543
|
+
bp = build_breakpoint_config(bp_data)
|
|
471
544
|
|
|
472
545
|
# Key by function + label
|
|
473
546
|
if bp.label && bp.function_name
|
|
@@ -6,6 +6,10 @@ module Tracekit
|
|
|
6
6
|
BreakpointConfig = Struct.new(
|
|
7
7
|
:id, :file_path, :line_number, :function_name, :label,
|
|
8
8
|
:enabled, :max_captures, :capture_count, :expire_at,
|
|
9
|
+
# v25 capture features
|
|
10
|
+
:condition, :condition_eval, :mode, :stack_depth,
|
|
11
|
+
:max_depth, :max_payload_bytes, :capture_expressions,
|
|
12
|
+
:idle_timeout_hours,
|
|
9
13
|
keyword_init: true
|
|
10
14
|
)
|
|
11
15
|
|
|
@@ -14,6 +18,8 @@ module Tracekit
|
|
|
14
18
|
:breakpoint_id, :service_name, :file_path, :function_name, :label,
|
|
15
19
|
:line_number, :variables, :security_flags, :stack_trace,
|
|
16
20
|
:trace_id, :span_id, :captured_at,
|
|
21
|
+
# v25 capture features
|
|
22
|
+
:expression_results, :mode,
|
|
17
23
|
keyword_init: true
|
|
18
24
|
)
|
|
19
25
|
|
data/lib/tracekit/version.rb
CHANGED
data/lib/tracekit.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tracekit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- TraceKit
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-04-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: opentelemetry-sdk
|
|
@@ -150,6 +150,7 @@ files:
|
|
|
150
150
|
- lib/tracekit.rb
|
|
151
151
|
- lib/tracekit/config.rb
|
|
152
152
|
- lib/tracekit/endpoint_resolver.rb
|
|
153
|
+
- lib/tracekit/evaluator.rb
|
|
153
154
|
- lib/tracekit/llm/anthropic_instrumentation.rb
|
|
154
155
|
- lib/tracekit/llm/common.rb
|
|
155
156
|
- lib/tracekit/llm/openai_instrumentation.rb
|