# Capture product events

> Measure application milestones, declare sampling, and inspect capture quality.

Canonical page: https://anectico.com/docs/instrument/diagnostic-events/


Product events record meaningful application
milestones such as `checkout_submitted`, `payment_failed`, or `agent_step_completed`.

Use them to measure workflows and connect customer behavior with errors, traces and replay.
Capture is explicit; broad automatic clickstream collection is not enabled.

Use a project-scoped key with `analytics:write`. Add `ingest:write` only when the same SDK also sends
traces, logs, metrics, or errors.

## Capture an event

<div data-language-tabs="diagnostic-event" data-language-tabs-label="Choose an SDK"></div>

**JavaScript / TypeScript**

```typescript
import { AnalyticsClient } from '@anectico/sdk/analytics';

const events = new AnalyticsClient({
  endpoint: 'https://api.anectico.com',
  apiKey: process.env.ANECTICO_API_KEY!,
});

events.capture('checkout_submitted', {
  cart_size: 3,
  payment_provider: 'stripe',
});
```

**Python**

```python
import os
import anectico

events = anectico.AnalyticsClient(
    endpoint='https://api.anectico.com',
    api_key=os.environ['ANECTICO_API_KEY'],
)
events.capture('checkout_submitted', {
    'cart_size': 3,
    'payment_provider': 'stripe',
}, distinct_id='user_8842')
```

**Go**

```go
events, err := anectico.NewAnalytics(anectico.AnalyticsConfig{
	Endpoint: "https://api.anectico.com",
	APIKey:   os.Getenv("ANECTICO_API_KEY"),
})
if err != nil {
	return err
}
defer func() { _ = events.Stop(ctx) }()

err = events.Capture(ctx, "checkout_submitted", map[string]any{
	"cart_size":        3,
	"payment_provider": "stripe",
}, anectico.WithDistinctID("user_8842"))
```

**Swift (iOS)**

```swift
Anectico.capture("checkout_submitted", properties: [
    "cart_size": 3,
    "payment_provider": "stripe",
])
```

**Kotlin (Android)**

```kotlin
Anectico.capture(
    "checkout_submitted",
    mapOf("cart_size" to 3, "payment_provider" to "stripe"),
)
```

**React Native**

```typescript
await Anectico.capture('checkout_submitted', {
  cart_size: 3,
  payment_provider: 'stripe',
});
```

**Flutter**

```dart
await Anectico.capture('checkout_submitted', properties: {
  'cart_size': 3,
  'payment_provider': 'stripe',
});
```

<div data-language-tabs-end="diagnostic-event"></div>

Stop the event client during graceful shutdown so buffered events can finish sending. In JavaScript,
the event client shares the active identity and browser session with the main SDK. Python and Go can
use the shared identity or an explicit request-scoped ID as shown above. Go's batch-oriented
`Analytics.Identify` queues a person merge without changing the process-global identity, so one
server can synchronize multiple people without attributing later background telemetry to the last
person. Use `client.Identify` only for the stateful single-user/client flow. Do not create a second
customer ID field inside event properties.

If you call `POST /api/v1/capture` directly, send exactly one JSON envelope containing the `events`
array. Concatenated JSON documents are malformed; the entire request is rejected and no prefix of
the batch is accepted.

## When to retry

If you call `POST /api/v1/capture` directly, retry on `429` and on any `5xx`, with backoff. A `503`
carries `Retry-After` and means the request never landed — this is also the answer when the upload is
cut short in transit, so a truncated or reset connection is safe to send again.

Follow the validated acknowledgement's retry instruction for each original position. An explicit
invalid-input refusal requires a correction; an oversized request must be split. An unrecognized
response remains uncertain at any HTTP status. Preserve original IDs, timestamps and payloads
when retrying uncertain positions, and do not resend accepted siblings. See
[capture acknowledgements](/docs/reference/rest-api#capture-acknowledgements).

## Know when events were dropped

`capture()` is non-blocking: it queues the event and returns before anything is sent, so it can never
tell you whether delivery worked. Three surfaces do.

**`flush()` tells you whether everything queued was accepted.** It reports success only when every
attempted event received a validated queue acknowledgement, or the queue was empty. HTTP `2xx`
alone is insufficient, and queue acceptance does not prove query visibility. When explicit refusals
were dropped it reports failure by value — `false` in JavaScript and Python, an error
wrapping `ErrAnalyticsDropped` in Go. JavaScript and Python raise when uncertain events remain
queued; Go reports `ErrAnalyticsRetryPending`. A mixed outcome can contain both drops and
uncertain events; inspect the counters and
callbacks as well. Go errors can match both `ErrAnalyticsDropped` and `ErrAnalyticsRetryPending`.

**A delivery handler tells you the moment a batch is dropped.** Pass `onDeliveryError` /
`on_delivery_error` / `OnDeliveryError` when you construct the client and it is called once per
dropped group of positions with the response status, the number dropped, and a stable lower-case reason
code such as `unauthorized` or `payload_too_large`. Retryable failures never call it — those events
are still queued. Per-item refusals can arrive in an HTTP `200` ledger; a drop count is not proof
that the corresponding user action never reached storage through an earlier attempt.

**A counter tells you the running total.** `stats()` / `Stats()` reports `recorded`, `delivered`
(events the server accepted), `dropped`, and `queued`. A non-zero `dropped` is worth alerting on: the
most common cause is a revoked or wrong-project API key, which rejects every batch with `401` or
`403` while the rest of your application carries on unaware.

Each drop is also written to the SDK's log at `WARN`, naming the status and the count.

In React Native, delivery happens inside the native queue, so `flush()` there reports that the flush
attempt finished and nothing more; see the React Native reference for what its bridge does report.

## Choose useful events

Record events that help an engineer answer a production question:

- a workflow entered a meaningful state;
- a rollout or provider choice changed behavior;
- a customer-visible operation succeeded or failed; or
- an agent or background job completed a significant step.

Avoid high-volume noise such as every mouse movement, DOM mutation, or internal function call.

## Verify

Open **Analytics → Events & trends**, select the event, and pivot to a customer who triggered it. The event should also
appear in that customer's timeline alongside surrounding errors, traces, and replay.

- [Identify customers](/docs/instrument/identity)
- [Inspect events and customer cohorts](/docs/investigate/events-and-cohorts)


## Declare product-event sampling

If your application or collector samples product events before capture, attach its declaration
to each submitted event's properties. Existing SDK property maps accept this object:

```json
{
  "environment": "production",
  "$anectico_product_sampling": {
    "source": "checkout-producer",
    "rate": 0.25
  }
}
```

The reserved object must contain exactly `source` and `rate`. `source` is a non-sensitive
identifier of 1–64 ASCII characters: start with a letter or digit, then use letters, digits,
periods, underscores, colons or hyphens. Use a producer label, never a person identifier or
credential. `rate` must be a finite JSON number greater than zero and at most one; strings,
null, extra fields and out-of-range values are refused as `INVALID_EVENT`. This property applies
only to product events; `identify` and `$groupidentify` cannot carry it. Other valid items in the
same batch still follow their own acknowledgements.

A rate below one is `declared_sampled`; one is `declared_unsampled`. An absent object is unknown.
The declaration is retained with the stored event and its frozen source evidence. It reports
what the client says; it does not identify an authorized source, prove that all upstream actions
were captured, or change collection behavior. Keep it unchanged when retrying an uncertain event.
Analytics measures observed events and does not scale funnel or retention results by this rate.
Overall source completeness remains unknown even when every submitted event declares rate one.

This product-event declaration is separate from temporary diagnostic trace sampling grants.
It does not measure events withheld by consent, missing instrumentation or upstream drops.

## Inspect capture-attempt quality

After sending events, inspect the project's retained attempt evidence with the CLI:

```bash
anectico --project <project-id> analytics quality \
  --start-time 2026-09-01T00:00:00Z --end-time 2026-09-02T00:00:00Z \
  --environment production
```

For MCP, discover the `get_capture_quality` read action, then call:

```json
{"name":"execute_read_action","arguments":{"action":"get_capture_quality","arguments":{"project_id":"<project-id>","start_time":"2026-09-01T00:00:00Z","end_time":"2026-09-02T00:00:00Z","environment":"production"}}}
```

Both require `analytics:read`; MCP additionally requires `mcp:read`. Capture-only
`analytics:write` cannot inspect attempts. Product and identity-control counts retain their
pending, queued, uncertain and not-attempted states; invalid and quota-refused positions are
reported separately. Counts include submitted retries and use coarse UTC-hour overlap. Invalid
positions cover all retained project attempts because their time/environment cannot be trusted.
Queued is not consumed, and zero counts do not establish completeness. Check SDK delivery
callbacks and query actual events to investigate uncertainty; do not automatically resend a whole
batch based on these counts. See [the response contract](/docs/reference/rest-api#capture-attempt-quality)
for limits, errors and the meaning of the digest.

The same response includes `sampling_provenance: "declared"`, `sampling_declarations` grouped by
source and rate, and `sampling_unknown` for product positions without a declaration. Each group
separates publication states from quota refusals. These counts include retries; two submissions
with the same message ID can appear in different declaration groups while storage retains the
original event. Identity-control and invalid-input counts remain separate. At most 100 distinct
source/rate groups can appear in the selected window; exceeding the limit refuses the entire
quality read. `source_completeness` stays `unknown`, including for empty results.
