Server-side event ingest
The VEKTIS tracker SDK is a thin client over a single HTTP endpoint. If you need to send events from a backend service, a batch job, a mobile-server pipeline, or anything else that isn’t a browser, you can call that endpoint directly.
This page is the HTTP reference. The browser SDK is documented under VEKTIS Tracker — pick that if your events originate on the client.
Endpoint
Section titled “Endpoint”POST https://events.vektis.io/api/v1/eventsContent-Type: application/jsonX-Vektis-Key: vk_prd_...Content-Type: application/json is required. Requests without it return 415 Unsupported Media Type.
Authentication
Section titled “Authentication”Pass your VEKTIS API key in the X-Vektis-Key header. Direct (non-browser) callers should always use the header.
For browsers using navigator.sendBeacon() on page-unload — where custom headers can’t be set — a ?key=<key> query-param fallback is accepted. The browser SDK uses this path automatically; server callers should not.
For server-side calls, use a Full (server-side) key (prefix vk_<env>_..., e.g. vk_prd_... for production), created on the API Keys page. The browser-safe Publishable keys (vk_pub_<env>_...) that the tracker SDK uses are also accepted, but they carry tighter rate limits — prefer a Full key from a backend caller.
Three things to know before you go and create one:
- The scope selector starts on Publishable. Change it to Full, or you get a browser key with the tighter limits. The reliable tell is the prefix: a Full key has no
pub_segment. - The environment selector starts on Development, which gives you a
vk_dev_...key. Switch it to Production for production traffic. Environment is fixed once the key exists — to change it, create another key. - Only an admin can create a key, and an organization can hold 10 active keys at a time. Revoke an unused one if you hit that.
Measurement is environment-blind: events sent with a development or staging key join the same population as production events for the same Feature ID. Send production traffic with a production key.
Request body
Section titled “Request body”The body is always a events array, even for a single event:
{ "events": [ /* 1 to 100 TrackingEvent objects */ ]}| Constraint | Value |
|---|---|
| Minimum events per request | 1 |
| Maximum events per request | 100 |
| Maximum body size | 512 KB |
Per-event fields
Section titled “Per-event fields”| Field | Required | Type | Notes |
|---|---|---|---|
event_id | yes | string (UUID) | Caller-generated UUID. Generate this when the event happens, not on retry — see Idempotency. |
event_type | yes | enum | One of the five values in Event types. Other values are rejected. |
customer_id | yes | string, 1–255 chars | Top-level on each event, not inside properties. |
feature_id | required for feature.* events | string, 1–255 chars | feature.used, feature.engagement, and feature.first_use all require this. |
user_id | for seat metrics | string, ≤255 chars | Optional at the API — an event without it is accepted and still counts toward overall and per-customer impact, but contributes no seat signal. Top-level on each event, not inside properties. Some transports have no user to send; see Identifying the user. |
action | for scoring | string, ≤255 chars | Optional at the API. Every Value Type that is derived from events matches on it — all but Overall activity and Revenue (manual) — and an event whose action name doesn’t match the dev item is never counted. Keep names to 200 characters or fewer — that is the limit on the dev item field, so a longer name can be accepted here and still never match. See Making events scoreable. |
properties | no | object | See constraints below. |
timestamp | no | ISO 8601 with offset (e.g., 2026-05-25T14:30:00Z) | Must fall within 7 days in the past to 1 hour in the future of server time. If omitted, the event is stamped on receipt — so if you buffer before flushing, set it explicitly. A Time to complete metric is measured from these stamps, and an event timed at flush skews it. |
properties constraints
Section titled “properties constraints”| Constraint | Value |
|---|---|
| Maximum keys | 50 |
| Maximum key length | 64 characters |
| Maximum string value length | 1024 characters |
| Maximum total JSON size | 8 KB |
| Allowed value types | string, number, boolean |
Event types
Section titled “Event types”event_type is a fixed enum. Other values return 400 Bad Request.
event_type | Notes |
|---|---|
feature.used | Primary event — a customer used a feature. Requires feature_id. |
feature.engagement | Engagement signal within a feature (e.g., depth of use). Requires feature_id. |
feature.first_use | First-time-use signal for a customer/feature pair. Requires feature_id. Optional: VEKTIS already derives each customer’s first use from the earliest event it holds for that Feature ID, so you do not have to track firstness yourself — no state, no “have I seen this customer before” lookup. |
session.active | Liveness for one customer. Counted as distinct customers seen, never as volume: extra events for a customer who is already sending events change nothing. Sending it for customers who never touch a feature lowers that feature’s Overall activity score — see What each Value Type counts. |
customer.identified | Associates a customer_id with a user_id, where you have one — see Identifying the user. Non-billable — does not count against your event quota. |
Making events scoreable
Section titled “Making events scoreable”Sending a valid event is not the same as sending a scoreable one. VEKTIS matches an incoming event to a dev item on two things:
- the Feature ID, and
- the action name.
Both must be present, and the action name must be character-for-character the one configured on the dev item. You set both under Edit dev item → “How should VEKTIS measure this?” → VEKTIS measures it for me, in the fields labelled Feature ID, Action name, Numerator action, Denominator action, Start action and End action.
If the two strings disagree — or if you send no action name at all — the metric stays empty. It is not an error and the request still returns 202.
Two Value Types are exceptions. Overall activity has no action field to configure and matches on Feature ID alone. Revenue (manual) is not derived from events at all, so there is nothing to match — see Which event type to send. Everything below that talks about matching action names applies to the other three.
Which event type to send
Section titled “Which event type to send”Everything you send is keyed to the dev item’s Value Type — the dropdown under “How should VEKTIS measure this?”. These five, named exactly as the dropdown names them:
| Value Type | Action fields on the dev item | What you send |
|---|---|---|
| Count occurrences | Action name | one feature.used per occurrence |
| Conversion rate | Denominator action | feature.engagement when the attempt starts |
| Numerator action | feature.used when it succeeds | |
| Time to complete | Start action, End action | feature.engagement for each, one at the start and one at the end |
| Overall activity | none — nothing to configure | feature.engagement at every meaningful interaction |
| Revenue (manual) | none | nothing — see below |
Two consequences worth reading twice:
- Conversion rate and Time to complete each need two different action names, one per event. Sending the same action for both halves gives VEKTIS nothing to divide or subtract.
- Revenue (manual) is not derived from events. Those numbers are entered by hand. Instrumenting a revenue feature produces billable events that can never turn into a metric.
The event type does not change how VEKTIS matches or scores an event — matching is on Feature ID plus action name. What the feature. prefix does control is that feature_id becomes required. So sending feature.engagement where the table says feature.used will not lose your data; use the table so your events read the way the rest of the product describes them.
What each Value Type counts
Section titled “What each Value Type counts”Metrics are derived one week at a time. Both halves of a two-event measurement have to land inside the same week, which matters most for Time to complete.
Count occurrences — how many events carried the Action name that week. Events, not customers: ten calls from one customer count as ten.
Conversion rate — distinct customers that sent the numerator action, divided by distinct customers that sent the denominator action, that week. It is not a ratio of event counts, so a customer retrying five times still counts once on each side. Send the denominator when the attempt begins and the numerator only when it succeeds — emit both at the success point and the rate sits at 100% forever.
Time to complete — VEKTIS pairs each start with the next end for the same customer_id, oldest first, and averages the gaps.
customer_idis the whole pairing key.user_idis not part of it, and neither is any property you attach. If two attempts are open at once under one customer, the first start pairs with the first end regardless of which attempt they belong to — so for a feature that runs concurrently per customer, treat the average as approximate.- A start with no end in the same week, and an end whose start fell in the previous week, are both dropped from that week’s average.
- Send an explicit
timestampon both events. An event without one is stamped on arrival, so any buffering or retry delay lands directly in the measurement.
Overall activity — no action names, no configuration, nothing to keep in sync. It combines reach (how many of your active customers sent any event for this Feature ID) with depth (how many events each of them sent), so:
- Action names here are free-form. Nothing matches on them. Use a distinct short one per call site —
viewed,searched,exported— so the events stay readable to you. - Every event for the Feature ID adds depth, whatever its action name or event type.
- The denominator is every customer who sent VEKTIS any event at all that week, for any feature. This is the one case where instrumenting more of your product lowers a score: a customer marked active who never reaches this feature makes its reach look smaller.
Confirming you got it right
Section titled “Confirming you got it right”Send one event, then open the dev item.
You do not have to catch it live, and there is no dialog to leave open — a server-side install is never confirmed in the moment you run it. Come back to the dev item after your first event and use the check below.
- No warning means the Feature ID and the action name both matched. You are done.
- A warning names the action name the dev item is configured for and lists the action names actually arriving, so you can see the mismatch side by side. An event sent with no action name appears in that list as
(no action name). - The summary at the top of the dev item also stops naming a scoring date while this is unresolved, because that date can no longer be met.
The warning lists each action name it is receiving, with how many events carried it. Pick the right one and VEKTIS starts measuring it straight away — no retyping. If none of them is right, change what your code sends instead. Events that arrived with no action name at all cannot be picked; those you fix in your code.
Instrumenting an API or MCP server
Section titled “Instrumenting an API or MCP server”What each event type means for a server
Section titled “What each event type means for a server”Roughly, and for readability only — the binding rule is the Value Type table above.
| Event type | On an API | On an MCP server |
|---|---|---|
feature.used | The endpoint representing that feature was called | A tool call that did real work for the customer |
feature.engagement | The richness of the request — query complexity, payload depth | The richness of what came back — result size, fields returned |
session.active | A burst of activity from one customer | A burst of tool calls in one agent session |
feature.first_use | The first time this customer called the endpoint | The first time this customer’s agent invoked the tool |
customer.identified | The authenticated identity behind the call | The identity in the server’s auth context |
Capture the shape of a request in properties — how much was asked for, how much came back — never the contents of the data itself.
MCP: count one successful terminal tool call
Section titled “MCP: count one successful terminal tool call”“A tool was called” is narrower than it sounds. One human question can produce five to fifty tool calls: pagination, retries after a malformed argument, multi-step prompts for missing input, exploratory queries the model discards. Counting all of them inflates the score the same way a cron job would, and a customer who switches to a chattier model watches their score rise with no change in real usage.
Emit feature.used only for a successful terminal tool call. All four conditions must hold.
Conditions 1–3 are read off the MCP specification, revision 2026-07-28; resultType and the multi-round-trip flow it belongs to were introduced there by SEP-2322.
| # | Condition | Excludes | Where it lives |
|---|---|---|---|
| 1 | No JSON-RPC protocol error | unknown tool, malformed request | protocol |
| 2 | isError is not true | the retry-after-bad-args loop | protocol |
| 3 | resultType is "complete" or absent | multi-round-trip intermediates | protocol |
| 4 | Args do not carry the tool’s own continuation/paging argument | pagination fan-out | per-tool |
- Condition 2 is exact, not a heuristic. The MCP specification defines tool execution errors (
isError: true) as “actionable feedback that language models can use to self-correct and retry with adjusted parameters” — that category is the retry class. - Condition 3 treats an absent
resultTypeas completed, so servers on specification revisions before 2026-07-28 are unaffected. Do not require the newest revision. The field is also not yet in the@modelcontextprotocol/sdkTypeScript types (as of 1.29.0), so read it defensively — as the example below does — rather than off the declaredCallToolResultshape. - Condition 4 is not a protocol field.
cursorandnextCursorbelong totools/listpagination and do not appear ontools/call. A paging token on a tool call is whatever that tool’s author declared in its owninputSchema. Name the continuation argument for each tool you instrument, or confirm the tool has none — there is no generic field to check. requestStateandinputResponsesdo NOT exclude. A multi-step flow produces exactly onecompleteresult: the first call returnsresultType: "input_required"(already excluded by condition 3), and the follow-up — which carriesinputResponses, andrequestStatewhere the server supplied it — is the one that succeeds. Excluding on those fields would drop the whole interaction and score every prompt-for-input tool zero. Condition 3 alone counts each chain exactly once.
A call that fails these conditions is not emitted at all. Do not emit it marked as automated traffic — see below.
Which event types the conditions govern:
| Event type | Gated by the four conditions? |
|---|---|
feature.used | Yes — the rule exists for it |
feature.first_use | Yes — it marks a customer’s first real invocation, so pinning it to a call that errored or was a pagination page is wrong. Optional either way: VEKTIS derives first use from the earliest event it holds for the Feature ID |
session.active | No — it is a liveness signal for one agent session, not a per-call event. Cadence does not matter, because it is counted as distinct customers seen: one per agent session is plenty, and a hundred more for the same customer change nothing. Skip it entirely if that customer is already sending feature events |
Telling customer activity from automated traffic
Section titled “Telling customer activity from automated traffic”Cron jobs, health checks and polling are not customers seeking value, and left unfiltered they measure your infrastructure instead of your product.
Mark an event by putting is_agent_call in properties, as a JSON boolean literal:
"properties": { "is_agent_call": true }true and false, never the strings "true" / "false", never 0 / 1.
- Absent means counted. An event with no marker is treated as customer activity. “Not explicitly marked as intent” is never treated as noise.
- The two surfaces label differently. On an MCP server the instrumentation lives inside your tool handler, so every event it emits is an agent call by construction — set
trueand move on. On an API, the handler already knows whether it is an intent endpoint or a cron/health path, so label from that context. - On an MCP server,
truemeans something narrower than it does on an API: not “an agent called this” but “one successful terminal tool call”, per the four conditions above.
Identifying the customer
Section titled “Identifying the customer”customer_id is required on every event, and it is what separates one customer from another in every metric. On an HTTP API it is native to your auth context. On an MCP server it frequently is not.
| Transport | Where customer_id comes from |
|---|---|
| Authenticated remote / HTTP MCP | A stable claim on the OAuth token |
| Unauthenticated remote / HTTP MCP | An environment variable — treat it like stdio |
| Local / stdio MCP | An environment variable from the env block of the client’s server config |
- Authenticated remote / HTTP MCP. Where your server implements authorization,
Authorization: Bearermust be present on every request, which fits MCP’s stateless model exactly. Validate the token audience, then derivecustomer_idfrom a stable claim —subfor a user, or a tenant claim where you bill accounts rather than users. If the token is opaque rather than a JWT the claim is not readable at your server: use token introspection, or a server-side mapping from token to customer. Do not parse an opaque token. - Unauthenticated remote / HTTP MCP. Authorization is optional in MCP; an unauthenticated remote server is legal and has no token at all. Treat it like stdio.
- Local / stdio MCP. The specification is explicit that stdio implementations should retrieve credentials from the environment rather than implementing authorization. So
customer_idcomes from theenvblock of the client’s server configuration, set at install time alongside your VEKTIS API key. That is the sanctioned path, not a workaround.
Identifying the user
Section titled “Identifying the user”user_id is optional, and unlike customer_id there are transports where a user does not exist at all. The rule is short: where an authenticated person is behind the call, send a stable identifier for them; where there isn’t one, send nothing.
It is the only input to the Seats number on a dev item’s Customer Breakdown — how many people at a customer used the feature, out of everyone there VEKTIS has seen using your product.
| Transport | Where user_id comes from |
|---|---|
| Authenticated HTTP API | Your auth context — see the API example |
Authenticated remote / HTTP MCP, customer_id from a tenant claim | The sub claim |
Authenticated remote / HTTP MCP, customer_id from sub | None separate — the account is the person. Omit it |
| Unauthenticated remote / HTTP MCP | None available — no token, no person. Omit it |
| Local / stdio MCP | None available — the env block identifies an install, not a person. Omit it |
- Authenticated HTTP API. Your auth context has already resolved the person. This is the case the examples on this page show.
- Authenticated remote / HTTP MCP. Which claim is left for the user depends on the choice you made for
customer_idabove. Where you derivedcustomer_idfrom a tenant claim,subidentifies the person and is what you send. Where you derivedcustomer_idfromsubitself — because you bill users rather than accounts — there is no second identifier: the account and the person are the same, and sendingsubas both produces a Seats number of 1 of 1 for every customer. That is not a seat metric. Omituser_id. - Unauthenticated remote / HTTP MCP. No token means no person. Omit it.
- Local / stdio MCP.
customer_idcomes from theenvblock of the client’s server configuration, which identifies an install rather than a person. A second environment variable does not fix this — whoever set it up is not necessarily whoever is calling. Omit it.
What omitting it looks like. Everything else works normally: the event is accepted, and it counts toward overall and per-customer impact exactly as it would otherwise. What you don’t get is the Seats number — that customer’s Seats cell shows a dash, and where no customer has a number the column is hidden entirely. See Customer Breakdown.
What to send, and what we do with it
Section titled “What to send, and what we do with it”Send a stable opaque application user id — your own internal identifier for that person, stable across their sessions, carrying no personal information.
| Don’t send | Why |
|---|---|
| A raw email address | Personal data outright. |
| A hashed email address | Reversible in practice against any list of email addresses — personal data with extra steps. |
| A per-session or per-request id | Defeats the purpose. Counting seats needs the same person to resolve to the same value across the whole measurement window. |
VEKTIS uses the value to count distinct users within a feature, and that count is the only thing it produces. No screen, export or drilldown ever renders the identifier, and there is no per-person view of behaviour across features at any level of detail. The value is stored on the event and is deleted when the event is.
Server-side identify
Section titled “Server-side identify”There is no dedicated /identify endpoint. To identify a customer from a server, post a customer.identified event:
{ "events": [{ "event_id": "550e8400-e29b-41d4-a716-446655440000", "event_type": "customer.identified", "customer_id": "acct_A1", "user_id": "user_123", "properties": { "name": "Acme Corp" } }]}properties.name is optional and is the only way to put a readable account name on the Customer Breakdown — without it, every row there is labelled with the raw customer_id. Send the account’s display name, not a person’s. The most recent non-empty name wins, so re-sending it as the account renames itself is enough to keep it current; names longer than 255 characters are truncated.
Responses
Section titled “Responses”Success: 202 Accepted
Section titled “Success: 202 Accepted”{ "accepted": 3 }accepted is the number of events that passed validation and were queued for ingestion. It is not a count of stored rows — duplicates by event_id are dropped silently during async processing (see Idempotency).
Errors
Section titled “Errors”All errors return JSON with at least statusCode and message:
{ "statusCode": 400, "message": "Validation failed", "errors": [ /* per-field issues */ ]}| Status | When | Notable body fields |
|---|---|---|
400 Bad Request | Schema validation failed, or the body was not valid JSON | errors[] (per-field issues, present for schema failures) |
401 Unauthorized | API key is missing or invalid | — |
413 Payload Too Large | Request body exceeded 512 KB | — |
415 Unsupported Media Type | Content-Type was not application/json | — |
429 Too Many Requests | Rate limit exceeded | retryAfter (seconds); also returned as the Retry-After HTTP header |
500 Internal Server Error | Unhandled server error | — |
Idempotency
Section titled “Idempotency”The server deduplicates events by event_id scoped to your account. Posting the same event_id twice results in the duplicate being dropped silently — no error, no double-count.
Two implications:
acceptedreflects queued events, not stored rows. If you re-send a batch after a network blip, the response can showaccepted: 100while none of those events are newly stored.- Generate
event_idwhen the event happens, not on retry. A fresh UUID per retry defeats dedupe; a stable UUID per event makes retries safe.
Rate limits
Section titled “Rate limits”Per-IP and per-API-key rate limits are enforced. When you exceed a limit you receive 429 with a Retry-After header (seconds). Honor Retry-After before resending.
Limits are not published numerically and may change. The SDK respects Retry-After automatically; direct callers should do the same.
Retry & batching guidance
Section titled “Retry & batching guidance”The browser SDK implements the pattern below. Server callers wanting parity can mirror it:
- Batch up to 100 events per request, or flush every ~5 seconds, whichever comes first.
- Honor
Retry-Afteron429and5xx. - Exponential backoff with jitter for retries (e.g., starting at 1 s, capped around 30 s, with ±30% jitter).
- Stop retrying a batch on
400or413— the payload is malformed or too large; re-sending won’t help. Log it and drop. - Stop sending entirely on
401— the key is bad or revoked; surface this to your operators. - Retry on
429and5xxwith backoff. - Limit retry attempts (the SDK uses 5) so a sustained outage doesn’t queue events forever.
Examples
Section titled “Examples”Single feature.used
Section titled “Single feature.used”curl -X POST https://events.vektis.io/api/v1/events \ -H "Content-Type: application/json" \ -H "X-Vektis-Key: $VEKTIS_KEY" \ -d '{ "events": [{ "event_id": "550e8400-e29b-41d4-a716-446655440000", "event_type": "feature.used", "feature_id": "reports-dashboard", "customer_id": "acct_A1", "user_id": "user_123", "action": "opened", "properties": { "tab": "overview" } }] }'customer.identified
Section titled “customer.identified”curl -X POST https://events.vektis.io/api/v1/events \ -H "Content-Type: application/json" \ -H "X-Vektis-Key: $VEKTIS_KEY" \ -d '{ "events": [{ "event_id": "550e8400-e29b-41d4-a716-446655440001", "event_type": "customer.identified", "customer_id": "acct_A1", "user_id": "user_123" }] }'Batched (feature.used + feature.engagement)
Section titled “Batched (feature.used + feature.engagement)”curl -X POST https://events.vektis.io/api/v1/events \ -H "Content-Type: application/json" \ -H "X-Vektis-Key: $VEKTIS_KEY" \ -d '{ "events": [ { "event_id": "550e8400-e29b-41d4-a716-446655440010", "event_type": "feature.used", "feature_id": "reports-dashboard", "customer_id": "acct_A1", "user_id": "user_123", "action": "opened" }, { "event_id": "550e8400-e29b-41d4-a716-446655440011", "event_type": "feature.engagement", "feature_id": "reports-dashboard", "customer_id": "acct_A1", "user_id": "user_123", "action": "export", "properties": { "format": "csv", "row_count": 1240 } } ] }'A complete example
Section titled “A complete example”The two examples above show the protocol. These show the instrumentation — the code that decides whether to send, and the dev item configuration it has to agree with. Both halves are shown deliberately: the mistake this page exists to prevent is the configuration and the code disagreeing.
Both examples use sendVektisEvent(...) as shorthand for a POST of a single event in the shape described under Request body.
An API endpoint
Section titled “An API endpoint”-
Configure the dev item.
Open Edit dev item → “How should VEKTIS measure this?” and choose VEKTIS measures it for me. Set:
Field Value Feature ID reports-exportValue Type Count occurrences Action name exportedexportedis the string the rest of this example has to match, character for character. -
Emit from the handler.
// POST /api/reports/:id/exportexport async function exportReport(req: Request, res: Response) {const report = await buildExport(req.params.id);res.json(report);// Emit after the work succeeded, never before — and never awaited. The// response has already been sent; a slow or failing analytics POST must not// become this request's latency or its unhandled rejection.sendVektisEvent({event_id: crypto.randomUUID(), // generated here; reuse it if you retryevent_type: "feature.used", // a count metric — see the Value Type tablefeature_id: "reports-export", // matches the Feature ID aboveaction: "exported", // matches the Action name abovecustomer_id: req.auth.accountId, // the account, not the individual useruser_id: req.auth.userId,properties: {is_agent_call: true, // an intent endpoint, not a cron pathrow_count: report.rows.length, // the shape of the answer, never its contents},}).catch((err) => logger.warn({ err }, "vektis event dropped"));} -
The event that produces.
{"events": [{"event_id": "550e8400-e29b-41d4-a716-446655440020","event_type": "feature.used","feature_id": "reports-export","customer_id": "acct_A1","user_id": "user_123","action": "exported","properties": { "is_agent_call": true, "row_count": 1240 }}]}"action": "exported"and"feature_id": "reports-export"are the same two strings you typed in step 1. That is the whole contract. -
Confirm.
Open the dev item. No warning means both matched.
Then prove the check works: change the Action name on the dev item to
export, send another event, and the dev item will name bothexportandexportedback to you. Change it back when you are done.
An MCP tool
Section titled “An MCP tool”Same four steps. The difference is that you decide whether to emit before you emit.
-
Configure the dev item.
Field Value Feature ID report-export-toolValue Type Count occurrences Action name exportedPut the tool name in Action name only if you are measuring one tool per dev item. Otherwise Action name carries the configured value and the tool name rides along in
properties, as below. -
Wrap the handler.
server.registerTool("export_report", schema, async (args, extra) => {const result = await runExport(args);// Condition 1 needs no code: a JSON-RPC protocol error means this handler// was never reached.const isTerminal =result.isError !== true && // condition 2(resultTypeOf(result) === "complete" ||resultTypeOf(result) === undefined) && // condition 3args.page_token === undefined; // condition 4 — THIS tool's// own paging argument. Name// yours, or confirm it has none.// Deliberately NOT checked: requestState / inputResponses. The leg that// carries them is the one that succeeded — excluding on them would score// every prompt-for-input tool zero.const customerId = customerIdFrom(extra);if (isTerminal && customerId) {// Not awaited: the customer's tool call must not wait on, or fail with,// an analytics POST.sendVektisEvent({event_id: crypto.randomUUID(),event_type: "feature.used",feature_id: "report-export-tool", // matches the Feature ID aboveaction: "exported", // matches the Action name abovecustomer_id: customerId,properties: {is_agent_call: true, // true by construction inside a tool handlertool: "export_report", // the tool name, since the metric isn't per-tool},}).catch(() => {}); // never let telemetry break the tool}return result;});// `resultType` is spec 2026-07-28 and not yet in the SDK's CallToolResult type,// so read it off the value rather than the declared shape. Absent means a server// on an older revision — treated as complete, per condition 3.function resultTypeOf(result: object): string | undefined {const value = (result as { resultType?: unknown }).resultType;return typeof value === "string" ? value : undefined;}function customerIdFrom(extra: { authInfo?: AuthInfo }): string | null {// `authInfo.extra` is where your token verifier puts the validated claims —// AuthInfo itself carries only token/clientId/scopes. For an opaque token,// populate it from introspection; never parse the token here.const sub = extra.authInfo?.extra?.["sub"];return ((typeof sub === "string" ? sub : undefined) ?? // authenticated remote MCPprocess.env.VEKTIS_CUSTOMER_ID ?? // stdio / unauthenticated remotenull // neither: emit nothing. Never substitute a constant or a); // generated id.} -
The event that produces.
{"events": [{"event_id": "550e8400-e29b-41d4-a716-446655440021","event_type": "feature.used","feature_id": "report-export-tool","customer_id": "acct_A1","action": "exported","properties": { "is_agent_call": true, "tool": "export_report" }}]} -
Confirm.
Same as above — open the dev item and expect no warning.
What gets counted in one agent turn
Section titled “What gets counted in one agent turn”Here is a realistic turn against that tool. One person asked one question:
| # | What the model did | Result | Emitted? |
|---|---|---|---|
| 1 | Called the tool with a malformed date | isError: true | No — condition 2 |
| 2 | Retried; the tool needs a missing field | resultType: "input_required" | No — condition 3 |
| 3 | Supplied the field; the call completes | resultType: "complete" | Yes — one event |
| 4 | Called again with page_token for page 2 | complete | No — condition 4 |
| 5 | Called a tool name that does not exist | JSON-RPC error | No — condition 1 |
One question, five tool calls, one event.
Without the guards this turn contributes four — and the same question asked of a chattier model contributes a different number again, so the score would move without usage moving. That is the inflation the conditions exist to stop.
Row 3 is also the reason inputResponses must not exclude: it is the leg carrying that field, and it is the only leg that succeeded. Guard on it and this entire turn scores zero.