|
| 1 | +# OpenFeature coding guide |
| 2 | + |
| 3 | +This guide applies to contributors and their AI coding tools working under |
| 4 | +`lib/datadog/open_feature/`, `spec/datadog/open_feature/`, and `sig/datadog/open_feature/`. |
| 5 | +The goal is to keep review conversations focused on design and correctness rather than |
| 6 | +conventions. Each rule is grounded in a real finding from this codebase; the guide grows as |
| 7 | +new patterns emerge. When in doubt, follow the existing files in this directory as the |
| 8 | +reference. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## Naming |
| 13 | + |
| 14 | +Ruby values intention-revealing names. Every local variable, method, and parameter should say |
| 15 | +what it holds or does. |
| 16 | + |
| 17 | +**Variables: use full, descriptive names.** |
| 18 | + |
| 19 | +```ruby |
| 20 | +# bad |
| 21 | +flat = flatten_context(attrs) |
| 22 | +e = entries.first |
| 23 | +s = build_string(parts) |
| 24 | + |
| 25 | +# good |
| 26 | +flattened_context = flatten_context(attrs) |
| 27 | +first_entry = entries.first |
| 28 | +encoded_string = build_string(parts) |
| 29 | +``` |
| 30 | + |
| 31 | +Single-letter names are acceptable only for established Ruby idioms: |
| 32 | + |
| 33 | +```ruby |
| 34 | +# fine: block index |
| 35 | +parts.each_with_index { |part, i| ... } |
| 36 | + |
| 37 | +# fine: rescue variable |
| 38 | +rescue => e |
| 39 | + logger.debug(e.message) |
| 40 | + |
| 41 | +# fine: throwaway / ignored argument |
| 42 | +def handle(event, _context) |
| 43 | +``` |
| 44 | + |
| 45 | +**Directories and modules must match.** A module named `FlagEvaluation` lives in |
| 46 | +`flag_evaluation/`, not `flagevaluation/`. Wire-level names (API endpoints, protocol fields) |
| 47 | +stay on the protocol side and do not leak into Ruby identifiers. |
| 48 | + |
| 49 | +**Use Ruby naming throughout, including comments and test descriptions.** Do not carry |
| 50 | +camelCase from other languages: |
| 51 | + |
| 52 | +```ruby |
| 53 | +# bad (in comment or RSpec description) |
| 54 | +# globalCap controls the upper bound |
| 55 | + |
| 56 | +# good |
| 57 | +# global_cap controls the upper bound |
| 58 | +``` |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +## Comments |
| 63 | + |
| 64 | +Comment the *why*, not the *what*. Use the minimum words needed to explain the constraint, |
| 65 | +decision, or non-obvious behaviour. If a comment keeps growing, that is a sign the code |
| 66 | +itself needs to be clearer — extract a method or rename a variable instead. |
| 67 | + |
| 68 | +```ruby |
| 69 | +# bad: narrates obvious code across multiple lines |
| 70 | +# We iterate over the context attributes and increment the counter for each |
| 71 | +# one we process, stopping once we reach the maximum allowed field count. |
| 72 | +count = 0 |
| 73 | +attrs.each { |k, v| count += 1 } |
| 74 | + |
| 75 | +# bad: one-liner that still just restates the code |
| 76 | +# increment the retry count |
| 77 | +retry_count += 1 |
| 78 | + |
| 79 | +# good: one line, explains a non-obvious constraint |
| 80 | +# Cap at 256 to match the backend's field limit; extras are silently dropped. |
| 81 | +pruned[key] = value if pruned.size < MAX_CONTEXT_FIELDS |
| 82 | +``` |
| 83 | + |
| 84 | +Do not restate a test description as a comment inside the example: |
| 85 | + |
| 86 | +```ruby |
| 87 | +# bad |
| 88 | +it "returns nil when the flag is missing" do |
| 89 | + # returns nil when the flag is missing |
| 90 | + expect(subject).to be_nil |
| 91 | +end |
| 92 | + |
| 93 | +# good |
| 94 | +it "returns nil when the flag is missing" do |
| 95 | + expect(subject).to be_nil |
| 96 | +end |
| 97 | +``` |
| 98 | + |
| 99 | +Do not include references that only make sense outside this repository (ticket IDs, "Node |
| 100 | +reference", "Python sibling"). The Ruby code stands alone. |
| 101 | + |
| 102 | +Use ASCII characters only. Do not use Unicode box-drawing dividers (`# ─── Section ───`); |
| 103 | +they are not used elsewhere in the codebase. |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## Methods |
| 108 | + |
| 109 | +Keep methods small and single-purpose. Prefer many focused private methods over one large one. |
| 110 | + |
| 111 | +```ruby |
| 112 | +# bad: one method doing too much |
| 113 | +def process(event) |
| 114 | + key = [event[:flag], event[:variant]].join(":") |
| 115 | + return if @seen.include?(key) |
| 116 | + @seen << key |
| 117 | + payload = { flag: event[:flag], count: (@counts[key] || 0) + 1 } |
| 118 | + @transport.send(payload) |
| 119 | +end |
| 120 | + |
| 121 | +# good: each step named and separated |
| 122 | +def process(event) |
| 123 | + key = cache_key(event) |
| 124 | + return if already_seen?(key) |
| 125 | + record(key) |
| 126 | + @transport.send(build_payload(key)) |
| 127 | +end |
| 128 | +``` |
| 129 | + |
| 130 | +Prefer `return unless condition` over `return nil unless condition` (Ruby implicitly returns |
| 131 | +nil): |
| 132 | + |
| 133 | +```ruby |
| 134 | +# bad |
| 135 | +return nil unless enabled? |
| 136 | + |
| 137 | +# good |
| 138 | +return unless enabled? |
| 139 | +``` |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +## Error handling |
| 144 | + |
| 145 | +Rescue only what you intend to handle, and only as broadly as necessary. |
| 146 | + |
| 147 | +```ruby |
| 148 | +# bad: catches everything, hides real bugs |
| 149 | +begin |
| 150 | + do_work |
| 151 | +rescue => e |
| 152 | + logger.debug(e.message) |
| 153 | +end |
| 154 | + |
| 155 | +# good: deliberate, narrow, explained |
| 156 | +@queue.push(event, true) |
| 157 | +rescue ThreadError |
| 158 | + # Queue full. Drop and count; backpressure is reported on the next flush. |
| 159 | + @overflow_count += 1 |
| 160 | +``` |
| 161 | + |
| 162 | +A broad `rescue => e` at a product boundary (flag evaluation, trace pipeline) is acceptable |
| 163 | +when the intent is to never interrupt the caller. Scope it tightly and comment why. |
| 164 | + |
| 165 | +--- |
| 166 | + |
| 167 | +## Types (RBS) |
| 168 | + |
| 169 | +Every file in `lib/datadog/open_feature/` has a matching signature in |
| 170 | +`sig/datadog/open_feature/`. Add or update the `.rbs` file in the same change as the Ruby |
| 171 | +file. CI enforces this. |
| 172 | + |
| 173 | +Do not suppress the type checker to make an error go away: |
| 174 | + |
| 175 | +```ruby |
| 176 | +# bad |
| 177 | +result = compute_value # steep:ignore |
| 178 | + |
| 179 | +# good: fix the type or add an explicit annotation |
| 180 | +result = compute_value #: String |
| 181 | +``` |
| 182 | + |
| 183 | +If a suppression is genuinely unavoidable, add a comment explaining why and call it out in |
| 184 | +the PR description. Prefer `Type?` over `(nil | Type)`. |
| 185 | + |
| 186 | +--- |
| 187 | + |
| 188 | +## Tests |
| 189 | + |
| 190 | +**Use verifying doubles.** `instance_double(RealClass)` raises when you stub a method that |
| 191 | +does not exist on the real class. String-name doubles and hand-rolled `Struct` fakes do not. |
| 192 | + |
| 193 | +```ruby |
| 194 | +# bad: passes even if Writer#enqueue is renamed or removed |
| 195 | +let(:writer) { double("Writer", enqueue: nil) } |
| 196 | +let(:writer) { Struct.new(:enqueue).new(nil) } |
| 197 | + |
| 198 | +# good: fails fast when the interface changes |
| 199 | +let(:writer) { instance_double(Datadog::OpenFeature::FlagEvaluation::Writer, enqueue: nil) } |
| 200 | +``` |
| 201 | + |
| 202 | +**Tests must be independent.** Each example must set up its own state and clean up after |
| 203 | +itself. Do not rely on test ordering or on state left by a previous example. This matters |
| 204 | +especially when a global singleton (like `OpenFeature::API.instance`) is involved: reset it |
| 205 | +in `before`/`after` hooks for every example that touches it, and stub every message an object |
| 206 | +will receive. |
| 207 | + |
| 208 | +**Assert exact values when you know them.** |
| 209 | + |
| 210 | +```ruby |
| 211 | +# bad: passes even if count is wildly wrong |
| 212 | +expect(result.count).to be >= 1 |
| 213 | + |
| 214 | +# good |
| 215 | +expect(result.count).to eq(3) |
| 216 | +``` |
| 217 | + |
| 218 | +--- |
| 219 | + |
| 220 | +## Concurrency and threads |
| 221 | + |
| 222 | +Background threads have a few mandatory properties in this codebase: |
| 223 | + |
| 224 | +- **Fork safety.** When a process forks (e.g. Puma spawning workers), all background threads |
| 225 | + from the parent process die silently in the child — the child starts with no threads even |
| 226 | + though the objects still exist. Use `Core::Workers::Async::Thread` with |
| 227 | + `FORK_POLICY_RESTART`: it detects that the process was forked and restarts the thread |
| 228 | + automatically on the next operation. |
| 229 | + |
| 230 | +- **Bounded queues.** Use `SizedQueue` with an explicit capacity rather than a plain `Array` |
| 231 | + or `Queue`. An unbounded queue accepts events faster than they can be flushed; under |
| 232 | + sustained load it grows until the process runs out of memory. A `SizedQueue` caps growth |
| 233 | + and lets the producer handle the overflow explicitly. |
| 234 | + |
| 235 | + ```ruby |
| 236 | + # bad: grows without limit |
| 237 | + @queue = Queue.new |
| 238 | + |
| 239 | + # good: caps at 4096 entries; producer handles ThreadError on overflow |
| 240 | + @queue = SizedQueue.new(4096) |
| 241 | + ``` |
| 242 | + |
| 243 | +- **Shutdown timeout.** Always call `join(timeout)` when stopping the thread. Without a |
| 244 | + timeout, a stuck thread prevents the process from exiting. |
| 245 | + |
| 246 | +- **Non-blocking enqueue.** The hook runs on the caller's flag-evaluation thread. Never let |
| 247 | + a full queue or a stopped worker stall that thread. Push non-blocking, catch the overflow, |
| 248 | + and count the drop — do not raise or wait: |
| 249 | + |
| 250 | + ```ruby |
| 251 | + # bad: blocks the caller if the queue is full |
| 252 | + @queue.push(event) |
| 253 | + |
| 254 | + # good: drops and counts on overflow, never blocks the caller |
| 255 | + @queue.push(event, true) |
| 256 | + rescue ThreadError |
| 257 | + @overflow_count += 1 |
| 258 | + ``` |
| 259 | + |
| 260 | +--- |
| 261 | + |
| 262 | +## Repo idioms |
| 263 | + |
| 264 | +These apply across the whole repository, not just this directory. |
| 265 | + |
| 266 | +**Time.** Use `Datadog::Core::Utils::Time.now` / `.get_time` instead of `Time.now`. The time |
| 267 | +provider is injectable (used by tests and Timecop integrations). |
| 268 | + |
| 269 | +```ruby |
| 270 | +# bad |
| 271 | +timestamp = Time.now.to_i |
| 272 | + |
| 273 | +# good |
| 274 | +timestamp = Core::Utils::Time.get_time |
| 275 | +``` |
| 276 | + |
| 277 | +**Environment variables.** Read through `Datadog::Core::Environment::VariableHelpers` or the |
| 278 | +settings layer, never `ENV` directly. Run `rake local_config_map:generate` after adding a new |
| 279 | +environment variable. |
| 280 | + |
| 281 | +**`filter_map`.** Use `Core::Utils::EnumerableCompat.filter_map` instead of the native |
| 282 | +`filter_map`. Native `filter_map` requires Ruby 2.7+; this codebase supports Ruby 2.5 and |
| 283 | +2.6. |
| 284 | + |
| 285 | +**Settings and component wiring.** New configuration belongs in `Configuration::Settings` |
| 286 | +extended via `Core::Configuration::Settings.extend`. New components are built in |
| 287 | +`Component.build` and wired into `components.rb`. Follow the existing wiring pattern rather |
| 288 | +than inventing a new one. |
| 289 | + |
| 290 | +**Accessing private members.** `.send(:member)` is an accepted pattern here when no public |
| 291 | +accessor exists — the tracer uses it in several places. Prefer adding a real public accessor |
| 292 | +or seam when one is practical. |
| 293 | + |
| 294 | +--- |
| 295 | + |
| 296 | +## Structure and PR hygiene |
| 297 | + |
| 298 | +- One class or module per file, matching the file name. |
| 299 | +- Keep PRs focused. Each PR should change one thing: a new feature, a refactor, or a bug |
| 300 | + fix — not all three at once. Mixing concerns makes it hard to understand intent, hard to |
| 301 | + revert, and slow to review. |
| 302 | +- No dead code. Dead code is any method, branch, or class that no current caller reaches. |
| 303 | + Common forms: a method defined but never called anywhere; a `rescue` branch for an |
| 304 | + exception the surrounding code cannot raise; an `if` branch whose condition is always |
| 305 | + false; a "forward compatibility" path added for a future caller that does not exist yet. |
| 306 | + Delete it — version control remembers it if it is ever needed again. |
| 307 | + |
| 308 | + ```ruby |
| 309 | + # dead: start_worker is defined but perform already starts the thread; |
| 310 | + # nothing calls start_worker |
| 311 | + def start_worker |
| 312 | + perform |
| 313 | + end |
| 314 | + |
| 315 | + # dead branch: the SDK never dispatches :finally today, so this branch |
| 316 | + # is never reached |
| 317 | + def run_hook(stage, context) |
| 318 | + case stage |
| 319 | + when :before then before(context) |
| 320 | + when :finally then finally(context) # no caller reaches here |
| 321 | + end |
| 322 | + end |
| 323 | + ``` |
| 324 | + |
| 325 | +- Always construct objects through their normal constructor. Do not use `.allocate` + |
| 326 | + `instance_variable_set` to bypass `initialize` — in tests, benchmarks, or anywhere else. |
| 327 | + If the constructor requires collaborators that are hard to supply, pass lightweight |
| 328 | + test doubles through the constructor arguments instead: |
| 329 | + |
| 330 | + ```ruby |
| 331 | + # bad: bypasses initialize, breaks silently when the constructor changes |
| 332 | + writer = Writer.allocate |
| 333 | + writer.instance_variable_set(:@transport, NoopTransport.new) |
| 334 | + writer.instance_variable_set(:@logger, logger) |
| 335 | + |
| 336 | + # good: uses the real constructor, passes a lightweight stand-in |
| 337 | + writer = Writer.new(transport: NoopTransport.new, logger: logger) |
| 338 | + ``` |
| 339 | + |
| 340 | +- **PR size.** If the resulting diff would exceed roughly 1000 lines of additions, stop and |
| 341 | + warn the contributor before generating the code. Propose a split into smaller, stackable |
| 342 | + PRs instead. Each PR should be reviewable on its own and mergeable independently. Get |
| 343 | + agreement on the breakdown before generating any code. |
| 344 | + |
| 345 | +### PR description |
| 346 | + |
| 347 | +Use `.github/PULL_REQUEST_TEMPLATE.md` as the structure. Fill each section with one or two |
| 348 | +sentences — high-level intent, not a list of every changed file. The reviewer reads the diff |
| 349 | +for details; the description should answer *what* and *why*, and call out any non-obvious |
| 350 | +trade-off or deliberate decision. |
| 351 | + |
| 352 | +``` |
| 353 | +**What does this PR do?** |
| 354 | +Adds an EVP writer that batches flag evaluation events and ships them to the Agent every 10s |
| 355 | +via the EVP proxy. The writer is fork-safe and drops events non-blocking on queue overflow. |
| 356 | +
|
| 357 | +**Motivation:** |
| 358 | +Required by the FFE telemetry spec. Without this, flag evaluation counts never reach the backend. |
| 359 | +
|
| 360 | +**Change log entry** |
| 361 | +Yes. Flag evaluation counts are now sent to Datadog via the EVP proxy. |
| 362 | +
|
| 363 | +**Additional Notes:** |
| 364 | +Chose a canonical context key over MD5 so the encoding is auditable without a digest |
| 365 | +dependency. Retry on transport failure is deferred — the writer logs and moves on for now. |
| 366 | +
|
| 367 | +**How to test the change?** |
| 368 | +Covered by the new aggregator and writer specs; integration verified against mock intake. |
| 369 | +``` |
| 370 | + |
| 371 | +Respond to every review comment with either a fix or an explanation of |
| 372 | +why the change is not being made. Do not re-request review with open threads unresolved. |
| 373 | + |
| 374 | +--- |
| 375 | + |
| 376 | +## Style |
| 377 | + |
| 378 | +Run `bundle exec rake standard:fix` before pushing. StandardRB is fixed and non-configurable |
| 379 | +by team convention. |
| 380 | + |
| 381 | +--- |
| 382 | + |
| 383 | +**Tool note.** Claude Code reads `CLAUDE.md`, not `AGENTS.md`. If you use Claude Code, |
| 384 | +symlink this file: `ln -s AGENTS.md CLAUDE.md` inside this directory. Verify how your |
| 385 | +specific tool (Cursor, Copilot, Codex) discovers nested guide files. |
0 commit comments