Runnable integration examples
Two small checkout services, before and after integrating product events and server-side feature flags, with a check that asks Anectico whether the exact test samples arrived.
On this page
These examples show one integration end to end: an application that sends nothing, the same application after your agent integrates product events and feature flags, a test that drives it like a visitor's browser, and a verification read that asks Anectico whether the exact events and flag exposures of that test arrived. They cover two server runtimes:
| Example | Runtime | Jobs | Where |
|---|---|---|---|
| Node checkout | TypeScript on Node.js 24 (or 22.18+), @anectico/sdk |
product events, feature flags (remote and local evaluation) | sdks/js/examples/node-checkout in the Anectico repository |
| Ruby checkout | Ruby 2.6+ with only the standard library, no Anectico SDK | product events, feature flags (remote decisions) | scripts/testing/examples/rest-ruby-checkout in the Anectico repository |
Each example has a baseline/ application that imports nothing from Anectico and calls nothing,
and an integrated/ application whose Anectico code is in one file. Compare the two to see the
whole integration. To have your own agent do this in your own repository, install the
anectico-integrate skill: it follows these two examples,
asks you before it changes anything and checks the exact samples against the
job verification.
The workflow around these examples is described in Ask your agent to integrate Anectico, and what a verified run does and does not establish is in What integration verification proves and what it does not.
Not covered yet: errors, traces and logs, session replay, surveys, product experiments, browser and
mobile applications, Go and Python. These limits describe the examples, not the SDK capability set.
Browser and mobile applications use separate client delivery keys with only flags:evaluate, never
the server definition-read key used here. See browser flags
and mobile flags and surveys.
Credentials and addresses
The application holds two keys and two addresses, all from server configuration. Your agent's own key is a third credential that only the verification uses.
| Setting | What it is |
|---|---|
ANECTICO_CONTROL_URL |
The address that serves flag decisions, for example https://app.anectico.com. |
ANECTICO_CAPTURE_URL |
The address that serves capture, for example https://api.anectico.com. |
ANECTICO_FLAGS_API_KEY |
A project-bound management key with only flags:read. It can read every flag definition of the project, so it stays in server secret configuration: never in browser code, a mobile app, a log or a repository. |
ANECTICO_CAPTURE_API_KEY |
A server_capture key (analytics:write) for events, identify, group and flag exposures. See native intake keys. |
ANECTICO_PROJECT_ID |
The project, required for local flag evaluation. |
Nothing falls back to a default address, and the application refuses to start when a setting is missing or both keys are the same key. The examples create no platform resources. The keys and the flag come from one of three branches, from the least authority to the most:
- A read-only agent connection creates no key. It tells you which keys the application needs (purpose and scope, as in the table) and you provide them in the application's environment.
- A management API key holding
mcp:write,api_key:writeand the scope of the key it creates (analytics:writefor the capture key,flags:readfor the flags key) lets an agent prepare and confirm each key when you ask, withcreate_intake_keyandcreate_api_key, for one exact project, after you approve its complete preview. The flag is created the same way, withflags:write. - You create the same keys yourself:
anectico apikey createprints the preview first, and you repeat the command with its--idempotency-keyand--confirm-tokento create the key.
Whoever creates a key, the secret goes only into the application's server configuration or secret store, never into source code, an example, a log or a transcript you share. A secret that was lost with its first response is never recovered: the key is revoked and a new one is created with your approval.
The Node example
Configuration is read once and checked before the server starts:
export const config = (() => {
const flagsKey = required("ANECTICO_FLAGS_API_KEY");
const captureKey = required("ANECTICO_CAPTURE_API_KEY");
if (flagsKey === captureKey) {
throw new Error("ANECTICO_FLAGS_API_KEY and ANECTICO_CAPTURE_API_KEY must be different keys");
}
const mode = process.env.ANECTICO_FLAG_EVALUATION ?? "remote";
if (mode !== "remote" && mode !== "local") {
throw new Error("ANECTICO_FLAG_EVALUATION must be remote or local");
}
return {
controlUrl: required("ANECTICO_CONTROL_URL"),
captureUrl: required("ANECTICO_CAPTURE_URL"),
flagsKey,
captureKey,
mode,
// Local evaluation is bound to one project; remote decisions take the
// project from the key.
projectId: mode === "local" ? required("ANECTICO_PROJECT_ID") : "",
checkoutFlag: process.env.CHECKOUT_FLAG_KEY ?? "new-checkout",
};
})();
Every capture reports its definitive outcome. An uncertain answer is retried a bounded number of times with the same message ID, which resends the original envelope; a refusal is never retried:
// Captures one event and reports its definitive outcome. An uncertain delivery
// is retried a bounded number of times with the SAME message ID, which resends
// the original envelope; a refusal is final and never retried.
async function captureWithOutcome(
events: AnalyticsClient,
event: string,
properties: Record<string, unknown>,
messageId: string,
): Promise<Delivery> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const result = await events.captureAndAck(event, properties, messageId);
return result.delivered
? { delivery: "queued" }
: { delivery: "refused", reason: result.rejected };
} catch (error) {
if (!(error instanceof CaptureAndAckError) || !error.retryable) break;
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
}
}
return { delivery: "uncertain" };
}
A Node server serves many people at once, so identity belongs to the request. Each request gets its own identity, built from the server's session (the visitor cookie before login, the user and account after), and its own event client:
// A per-request scope: its own identity and event client, and the exposure
// deliveries it started, so the request can wait for them before it ends.
class RequestScope {
readonly person: RequestPerson;
readonly requestId: string;
readonly identity: IdentityManager;
readonly events: AnalyticsClient;
readonly pending: Promise<void>[] = [];
constructor(person: RequestPerson, requestId: string) {
this.person = person;
this.requestId = requestId;
// The visitor ID from the cookie is this request's anonymous ID.
this.identity = new IdentityManager({
getItem: () => person.visitorId,
setItem: () => {},
removeItem: () => {},
});
if (person.userId) this.identity.identify(person.userId);
if (person.accountId) this.identity.group("company", person.accountId);
this.events = new AnalyticsClient({
endpoint: config.captureUrl,
apiKey: config.captureKey,
identity: this.identity,
flushInterval: 0,
});
}
distinctId(): string {
return this.identity.getDistinctId();
}
// Exposure transport for both flag clients. The message ID is derived from
// the request and the flag, so a retry of the same exposure keeps its ID.
exposure(event: string, properties: Record<string, unknown> | undefined, distinctId: string): Promise<void> {
const props = properties ?? {};
const messageId = `${this.requestId}:${String(props.$feature_flag)}`;
const delivered = captureWithOutcome(this.events, event, props, messageId).then((outcome) => {
record({ kind: "capture", event, message_id: messageId, distinct_id: distinctId, flag: exposureFields(props), ...outcome });
});
this.pending.push(delivered);
return delivered;
}
}
Events carry an ID the application chose, so a test can find them again. At login, identify links
the anonymous visitor to the person and group records the account:
export async function track(
scope: RequestScope,
event: string,
properties: Record<string, unknown>,
suffix: string,
): Promise<void> {
const messageId = `${scope.requestId}:${suffix}`;
const outcome = await captureWithOutcome(scope.events, event, properties, messageId);
record({ kind: "capture", event, message_id: messageId, distinct_id: scope.distinctId(), ...outcome });
}
// Login links the anonymous visitor to the person and records the account.
export function identifyLogin(scope: RequestScope, userId: string, plan: string, accountId: string): void {
const anonymousId = scope.identity.getAnonId();
scope.events.identify(userId, { plan });
scope.events.group("company", accountId);
record({ kind: "identify", distinct_id: userId, anon_distinct_id: anonymousId, group: { company: accountId } });
}
The checkout reads the flag for the request's person. The served value is the branch the application takes, and the exposure records the decision's own definition version, status, fallback and input context. A decision that cannot be read serves the default and records no exposure:
// Reads the checkout flag for the request's person. The served value is the
// branch the application takes; an unavailable decision serves the default
// and records no exposure.
export async function checkoutVariant(scope: RequestScope, testCaseId?: string): Promise<boolean> {
const personProperties = { plan: scope.person.plan ?? "free" };
const options = testCaseId ? { testCaseId } : undefined;
if (local) {
const result = local.evaluate(config.checkoutFlag, scope.distinctId(), personProperties, options);
if (result.value === undefined) {
record({ kind: "flag_default", flag_key: config.checkoutFlag, mode: "local", evaluation_status: result.evaluation_status, reason: result.error ?? result.reason });
}
return result.value === true;
}
const flags = new FlagsClient(
{ endpoint: config.controlUrl, apiKey: config.flagsKey },
(event, properties, distinctId) => scope.exposure(event, properties, distinctId),
);
try {
// Explicit inputs only: no groups, so the decision's input binding equals
// the local one for the same person and properties.
await flags.reload(scope.distinctId(), personProperties, {});
} catch {
record({ kind: "flag_default", flag_key: config.checkoutFlag, mode: "remote", evaluation_status: "unavailable_flag" });
return false;
}
const details = flags.getFeatureFlagDetails(config.checkoutFlag, options);
if (details.value === undefined) record({ kind: "flag_default", flag_key: config.checkoutFlag, mode: "remote", evaluation_status: details.evaluation_status });
return details.value === true;
}
With ANECTICO_FLAG_EVALUATION=local, the server keeps one flag snapshot and evaluates in process.
Local evaluation supports person-property targeting only:
// Local evaluation keeps one snapshot for the process; its exposure callback
// runs inside evaluate() and routes to the current request's scope.
const local =
config.mode === "local"
? new LocalFlagsClient(
{ endpoint: config.controlUrl, apiKey: config.flagsKey, projectId: config.projectId },
(event, properties, distinctId) => {
void scopes.getStore()?.exposure(event, properties, distinctId);
},
)
: undefined;
export async function startFlags(): Promise<void> {
await local?.refresh();
}
This example binds no session: its events carry the process-wide session, so it declares none and
its verification does not check sessions. To give each request a session of its own, pass a
SessionManager next to the identity, as
request-scoped clients
shows.
The Ruby example (no SDK)
The same application over the HTTP API. Configuration:
def self.required(name)
value = ENV[name].to_s.strip
raise ArgumentError, "#{name} is required" if value.empty?
value
end
def self.address(name)
uri = URI.parse(required(name))
unless %w[http https].include?(uri.scheme) && uri.host && uri.userinfo.nil? && uri.query.nil?
raise ArgumentError, "#{name} must be an http(s) URL without credentials or query"
end
uri.to_s.sub(%r{/+\z}, '')
end
CONTROL_URL = address('ANECTICO_CONTROL_URL')
CAPTURE_URL = address('ANECTICO_CAPTURE_URL')
FLAGS_KEY = required('ANECTICO_FLAGS_API_KEY')
CAPTURE_KEY = required('ANECTICO_CAPTURE_API_KEY')
raise ArgumentError, 'ANECTICO_FLAGS_API_KEY and ANECTICO_CAPTURE_API_KEY must be different keys' if FLAGS_KEY == CAPTURE_KEY
CHECKOUT_FLAG = ENV.fetch('CHECKOUT_FLAG_KEY', 'new-checkout')
One capture envelope is serialized once and resent unchanged while the answer is uncertain:
# Sends one capture envelope and returns its definitive outcome. The body is
# serialized once: an uncertain answer (an UNKNOWN item, 429, 503, a timeout
# or a broken connection) resends the SAME bytes, with the same message ID,
# a bounded number of times. A refusal is final and never retried.
def self.capture(event)
body = JSON.generate(events: [event])
3.times do |attempt|
sleep(0.25 * attempt) if attempt.positive?
begin
response = post(CAPTURE_URL, '/api/v1/capture', CAPTURE_KEY, body)
ack = JSON.parse(response.body)
rescue StandardError
next
end
if ack['batch']
item = ack['batch']['items'][0]
case item['delivery']
when 'CAPTURE_DELIVERY_QUEUED' then return { delivery: 'queued' }
when 'CAPTURE_DELIVERY_REJECTED' then return { delivery: 'refused', reason: item['code'] }
end
next # CAPTURE_DELIVERY_UNKNOWN: retry with the same message ID
end
code = ack.dig('failure', 'code')
next if [429, 503].include?(response.code.to_i)
return { delivery: 'refused', reason: code }
end
{ delivery: 'uncertain' }
end
Events, identify and group. The browsing session is owned by the request, so events carry it:
# The person comes from the server's own session: distinct_id is the user ID
# once logged in and the visitor ID before. session_id is the browsing
# session of this request. groups are the account at event time.
def self.track(person, event, properties, message_id)
properties = properties.merge('$groups' => { 'company' => person[:account_id] }) if person[:account_id]
envelope = {
event: event, distinct_id: person[:distinct_id], session_id: person[:session_id],
properties: properties, message_id: message_id, timestamp: Time.now.utc.iso8601(3)
}
outcome = capture(envelope)
record({ kind: 'capture', event: event, message_id: message_id, distinct_id: person[:distinct_id],
session_id: person[:session_id] }.merge(outcome))
outcome
end
# Login: a literal identify links the anonymous visitor to the person, then
# $groupidentify records the person's account. Neither stores an event row.
def self.identify_login(person, user_id, plan, account_id)
identify = { event: 'identify', distinct_id: user_id, anon_distinct_id: person[:distinct_id],
session_id: person[:session_id], set: { plan: plan },
message_id: SecureRandom.uuid, timestamp: Time.now.utc.iso8601(3) }
group = { event: '$groupidentify', distinct_id: user_id, session_id: person[:session_id],
group_type: 'company', group_key: account_id,
message_id: SecureRandom.uuid, timestamp: Time.now.utc.iso8601(3) }
[identify, group].each do |envelope|
record({ kind: envelope[:event] == 'identify' ? 'identify' : 'group', distinct_id: user_id,
anon_distinct_id: envelope[:anon_distinct_id], message_id: envelope[:message_id],
group: envelope[:group_key] ? { company: account_id } : nil }.merge(capture(envelope)))
end
end
The decision and its exposure. The exposure carries the reserved properties described in feature-flag exposure verification:
# Asks for the person's decision, serves the value and records one exposure
# with the decision's own version, status, fallback and input context. An
# unavailable decision serves the default and records no exposure.
def self.checkout_variant(person, request_id, test_case_id = nil)
person_properties = { 'plan' => person[:plan] || 'free' }
begin
response = post(CONTROL_URL, '/api/v1/decide', FLAGS_KEY,
JSON.generate(distinct_id: person[:distinct_id], person_properties: person_properties))
raise "decide: unexpected status #{response.code}" unless response.code.to_i == 200
decision = JSON.parse(response.body)
rescue StandardError
record(kind: 'flag_default', flag_key: CHECKOUT_FLAG, mode: 'remote', evaluation_status: 'unavailable_flag')
return false
end
flags = decision['feature_flags'] || {}
unless flags.key?(CHECKOUT_FLAG)
record(kind: 'flag_default', flag_key: CHECKOUT_FLAG, mode: 'remote', evaluation_status: 'unavailable_flag')
return false
end
value = flags[CHECKOUT_FLAG]
result = (decision['feature_flag_results'] || {})[CHECKOUT_FLAG] || {}
context = { 'basis' => result.dig('input_context', 'basis') || 'unestablished' }
present = result.dig('input_context', 'required_inputs_present')
context['required_inputs_present'] = present unless present.nil?
if decision['project_id'].is_a?(String) && !decision['project_id'].empty?
binding = input_binding(decision['project_id'], CHECKOUT_FLAG, person[:distinct_id], person_properties)
context['input_binding'] = binding if binding
end
props = { '$feature_flag' => CHECKOUT_FLAG, '$feature_flag_response' => value,
'$feature_flag_evaluation' => 'remote', '$feature_flag_input_context' => context }
props['$feature_flag_evaluation_status'] = result['evaluation_status'] if result['evaluation_status']
props['$feature_flag_is_fallback'] = result['is_fallback'] unless result['is_fallback'].nil?
version = (decision['feature_flag_versions'] || {})[CHECKOUT_FLAG]
props['$feature_flag_version'] = version if version && result['evaluation_status'] == 'evaluated'
props['$feature_flag_test_case_id'] = test_case_id if test_case_id
message_id = "#{request_id}:#{CHECKOUT_FLAG}"
envelope = { event: '$feature_flag_called', distinct_id: person[:distinct_id], session_id: person[:session_id],
properties: props, message_id: message_id, timestamp: Time.now.utc.iso8601(3) }
outcome = capture(envelope)
record({ kind: 'capture', event: '$feature_flag_called', message_id: message_id,
distinct_id: person[:distinct_id], session_id: person[:session_id],
flag: { key: CHECKOUT_FLAG, value: value, definition_version: props['$feature_flag_version'],
evaluation_status: props['$feature_flag_evaluation_status'],
is_fallback: props['$feature_flag_is_fallback'], mode: 'remote',
test_case_id: test_case_id, input_context: context } }.merge(outcome))
value == true
end
The input binding is the application's declaration of the explicit inputs it evaluated with:
# The input binding: sha256 over canonical JSON (keys sorted by code point,
# no whitespace) of exactly the explicit inputs the decision used. Integers,
# strings, booleans and null only; anything else omits the binding.
def self.canonical(value)
case value
when Hash
# Ruby compares strings bytewise, which for UTF-8 is code point order.
pairs = value.map { |k, v| [k.to_s, v] }.sort_by(&:first)
'{' + pairs.map { |k, v| "#{JSON.generate(k)}:#{canonical(v)}" }.join(',') + '}'
when Array then '[' + value.map { |v| canonical(v) }.join(',') + ']'
when String, Integer, true, false, nil then JSON.generate(value)
else raise ArgumentError, 'input binding cannot represent the value'
end
end
def self.input_binding(project_id, flag_key, distinct_id, person_properties)
description = canonical('format' => 'anectico.flag_inputs.v1', 'project_id' => project_id,
'flag_key' => flag_key, 'distinct_id' => distinct_id,
'person_properties' => person_properties, 'groups' => {})
"sha256:#{Digest::SHA256.hexdigest(description)}"
rescue ArgumentError
nil
end
Run and verify
The test drives one browser through an anonymous visit, a login by person A (plan pro, account
A), a checkout, a logout, a second anonymous visit, a login by person B (plan free, account B), a
checkout and a logout. With a flag new-checkout that is true for plan pro, A gets the new
checkout and B the classic one.
From a checkout of the Anectico repository, with the settings above, the capture key's source ID
(ingress_source_id in anectico apikey list -o json) as ANECTICO_CAPTURE_SOURCE_ID, and your
agent's CLI credential as ANECTICO_API_URL and ANECTICO_API_KEY:
python3 scripts/testing/examples/run_example.py live --variant js-node-server
python3 scripts/testing/examples/run_example.py live --variant js-node-server --flag-mode local
python3 scripts/testing/examples/run_example.py live --variant rest-ruby-server
Add --sdk pack to the Node run to install the SDK from the npm tarball that npm pack builds in
sdks/js, the artifact supplied during early access; the default links the repository's SDK.
The run prints one selected output: the runtime and artifact, the exercise clocks, every event and
exposure with its message ID, identity and delivery outcome (queued, refused or
uncertain), the identify and group calls, and what the run does not establish. It then sends one
integration job verification per job with
anectico doctor integration, selecting only the samples that were queued, and reads again with the
same request at most four times over about a minute. A refused or uncertain sample is reported, never
selected and never resent to make it appear.
The verifying key needs query:read, analytics:read and flags:read, plus persons:read for the
identity checks. With persons:read the run also compares the people behind the events: the
anonymous visit before each login must resolve to the person who logged in, and A and B must stay
two people. The run succeeds only when both jobs are verified_sample and those identity relations
hold.
A verified run proves that the selected samples of this run arrived as the application reported them. It does not prove that other events, people or code paths are instrumented, that every event arrived, or that anyone saw a treatment.