Skip to content
Console
Browse documentation
Guide

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:write and the scope of the key it creates (analytics:write for the capture key, flags:read for the flags key) lets an agent prepare and confirm each key when you ask, with create_intake_key and create_api_key, for one exact project, after you approve its complete preview. The flag is created the same way, with flags:write.
  • You create the same keys yourself: anectico apikey create prints the preview first, and you repeat the command with its --idempotency-key and --confirm-token to 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.