Skip to content

Commit b85232d

Browse files
authored
Merge pull request #5968 from DataDog/tony.hsu/open-feature-agents-guide
docs(open_feature): add LLM coding guide for FFE contributors
2 parents 3bce966 + 709537d commit b85232d

3 files changed

Lines changed: 388 additions & 2 deletions

File tree

datadog.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Gem::Specification.new do |spec|
4848
.select { |fn| File.file?(fn) } # We don't want directories, only files
4949
.reject { |fn| fn.end_with?('.so', '.bundle') } # Exclude local profiler binary artifacts
5050
.reject { |fn| fn.end_with?('skipped_reason.txt') } # Generated by profiler; should never be distributed
51+
.reject { |fn| File.basename(fn) == 'AGENTS.md' } # Developer tooling; not useful to gem consumers
5152

5253
spec.executables = ['ddprofrb']
5354
spec.require_paths = ['lib']

lib/datadog/open_feature/AGENTS.md

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
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

Comments
 (0)