Module: Condux::Scope

Defined in:
lib/condux/scope.rb

Overview

Ambient event enrichment: who the user is, which tags and contexts apply, and the breadcrumb trail leading up to an error. Set once (or as the app's state changes) and every subsequent event carries it — the first triage questions ("which customer, which plan, what did they do last") answered without threading anything through capture calls. The relay already scrubs all of these at ingest and derives the pseudonymous users-affected key from the user fields. There are two layers, and the distinction is the whole point:

Process state, set at boot and shared by everything. Right for facts about the deployment.
A request scope, active only inside Scope.request. Right for facts about one request.

Without the second, set_user in a Rails controller is a cross-request leak: Puma serves requests concurrently, so one request's user would attach to another request's error. That is worse than reporting no user, because it is confidently wrong and points an investigation at the wrong customer. The Rack middleware opens a scope per request, so a set_user in a controller stays in that request.

Constant Summary collapse

MAX_BREADCRUMBS =

Newest trail wins: a long-lived process drops the oldest crumbs rather than growing without bound.

30
STATE_KEY =

Thread.current is FIBER-local in Ruby, unlike thread_variable_get which is thread-local. Fiber local is what this wants: it isolates Puma's threads and also Falcon's fibers, where several requests share one thread and a thread-local would let them see each other's user.

:condux_request_scope

Class Method Summary collapse

Class Method Details

.add_breadcrumb(message, category: nil, level: nil, type: nil, data: nil, timestamp: nil) ⇒ Object

Record a breadcrumb; the trail (newest last, capped) rides every subsequent event.



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/condux/scope.rb', line 99

def add_breadcrumb(message, category: nil, level: nil, type: nil, data: nil, timestamp: nil)
  crumb = { "message" => message, "timestamp" => timestamp || Time.now.to_f }
  crumb["category"] = category if category
  crumb["level"] = level if level
  crumb["type"] = type if type
  crumb["data"] = data if data

  state = request_state
  if state
    state[:breadcrumbs] << crumb
    state[:breadcrumbs].shift while state[:breadcrumbs].length > MAX_BREADCRUMBS
    return
  end

  @mutex.synchronize do
    @breadcrumbs << crumb
    @breadcrumbs.shift while @breadcrumbs.length > MAX_BREADCRUMBS
  end
end

.clearObject

Reset all ambient state (tests, or a full sign-out). Clears the request scope when one is active.



120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/condux/scope.rb', line 120

def clear
  state = request_state
  if state
    state.replace(user: nil, tags: {}, contexts: {}, breadcrumbs: [])
    return
  end

  @mutex.synchronize do
    @user = nil
    @tags = {}
    @contexts = {}
    @breadcrumbs = []
  end
end

.fieldsObject

The scope's contribution to an event, holding only the keys that are actually set so an unenriched event keeps its exact wire shape. Breadcrumbs use the Sentry => [] envelope.

The request scope layers OVER the process state rather than replacing it, so a request keeps the deployment-wide tags while overriding the ones it sets itself.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/condux/scope.rb', line 140

def fields
  state = request_state || {}
  process = @mutex.synchronize do
    { user: @user&.dup, tags: @tags.dup, contexts: @contexts.dup, breadcrumbs: @breadcrumbs.dup }
  end

  user = state[:user] || process[:user]
  tags = process[:tags].merge(state[:tags] || {})
  contexts = process[:contexts].merge(state[:contexts] || {})
  # Concatenated, not merged: the trail is a sequence, and the process-level crumbs genuinely
  # happened before the ones recorded during the request.
  breadcrumbs = (process[:breadcrumbs] + (state[:breadcrumbs] || [])).last(MAX_BREADCRUMBS)

  fields = {}
  fields["user"] = user.dup if user
  fields["tags"] = tags unless tags.empty?
  fields["contexts"] = contexts unless contexts.empty?
  fields["breadcrumbs"] = { "values" => breadcrumbs } unless breadcrumbs.empty?
  fields
end

.requestObject

Isolate enrichment to one request. Anything set inside is visible only to events captured inside. The Rack middleware wraps every request in this; call it directly around a background job, which has the same problem of many in flight at once.



37
38
39
40
41
42
43
44
45
46
# File 'lib/condux/scope.rb', line 37

def request
  previous = Thread.current[STATE_KEY]
  Thread.current[STATE_KEY] = { user: nil, tags: {}, contexts: {}, breadcrumbs: [] }
  yield
ensure
  # Always restore. Puma reuses threads, so state left behind is handed to the next request that
  # worker picks up, which is the exact leak this exists to prevent. Restoring the previous value
  # rather than clearing keeps nesting honest.
  Thread.current[STATE_KEY] = previous
end

.request_stateObject

Nil when no request is in flight, so writes fall through to process state and a boot-time set_tag behaves exactly as it did before request scopes existed.



50
51
52
# File 'lib/condux/scope.rb', line 50

def request_state
  Thread.current[STATE_KEY]
end

.set_context(name, context) ⇒ Object

Attach a named context object to subsequent events; nil removes it.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/condux/scope.rb', line 81

def set_context(name, context)
  value = context&.transform_keys(&:to_s)
  state = request_state
  if state
    value.nil? ? state[:contexts].delete(name.to_s) : state[:contexts][name.to_s] = value
    return
  end

  @mutex.synchronize do
    if value.nil?
      @contexts.delete(name.to_s)
    else
      @contexts[name.to_s] = value
    end
  end
end

.set_tag(key, value) ⇒ Object

Attach a tag to subsequent events; a nil value removes it.



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/condux/scope.rb', line 64

def set_tag(key, value)
  state = request_state
  if state
    value.nil? ? state[:tags].delete(key.to_s) : state[:tags][key.to_s] = value
    return
  end

  @mutex.synchronize do
    if value.nil?
      @tags.delete(key.to_s)
    else
      @tags[key.to_s] = value
    end
  end
end

.user=(user) ⇒ Object

Attach the signed-in user (id/email/username) to subsequent events; nil clears. Inside a request scope this applies to that request alone; outside one it is process wide.



55
56
57
58
59
60
61
# File 'lib/condux/scope.rb', line 55

def user=(user)
  value = user&.transform_keys(&:to_s)
  state = request_state
  return state[:user] = value if state

  @mutex.synchronize { @user = value }
end