Theme
Server-side — any language over plain HTTP
We ship SDKs for a handful of languages. Yours may not be one of them, and that is fine: the SDKs are conveniences, not requirements. Everything an SDK does is one HTTP call. Any language that can POST JSON can send events to Tangent, with no feature gap and no second-class treatment.
This page is for teams writing their own client. It covers the shape of the call, complete working clients in Ruby, PHP, C# and shell, and — the part that matters — what a good minimal client must implement so that your integration behaves as well as ours do under load, offline, and during an incident.
For the full field reference — every field, every validation rule, every response shape — see http-ingest.md. This page does not repeat it.
1. What you get
One endpoint. One header. A JSON body containing a list of events.
bash
curl -X POST https://collect.tangentlens.com/collector/v1/events \
-H "X-API-Key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"events":[{"eventType":"report_exported","userId":"user-1042"}]}'That is a complete integration. Everything else on this page is about doing it well.
Use a secret key
| Prefix | Kind | Where it belongs |
|---|---|---|
sk_live_ / sk_test_ | Secret | Your servers. Rejected if the request carries an Origin header. |
pk_live_ / pk_test_ | Public | Browsers and mobile apps, restricted to registered origins. |
Server-side code uses a secret key. It is not restricted by origin, so it must never reach a browser, a mobile binary, a client-side bundle, or a public repository. Load it from your environment or secret manager:
bash
export TANGENT_KEY="sk_live_xxx"The tenant, app and environment all come from the key. A tenantId in the body is ignored.
2. The request
POST https://collect.tangentlens.com/collector/v1/events
X-API-Key: sk_live_xxx
X-Selica-Sdk: <language>/<version> (optional, for attribution)
Content-Type: application/jsonjson
{
"events": [
{
"messageId": "6f1c9a2e-4d3b-4a51-9c88-2b7e10f4a3d9",
"eventType": "plan_upgraded",
"eventTime": "2026-09-06T10:00:00.000Z",
"userId": "user-1042",
"sessionId": "sess-88f2",
"properties": { "from": "starter", "channel": "api" },
"revenue": { "price": 49.00, "quantity": 3, "currency": "USD", "itemId": "team-pro" },
"entities": [
{ "schema": "com.acme/[email protected]", "data": { "id": "acct-42", "tier": "pro" } }
]
}
]
}The rules you must satisfy, in brief — see http-ingest.md for the complete set:
eventTypeis required.userIdanddeviceIdare both optional, each at least 5 characters when present. An event with neither is accepted as anonymous: it counts in totals but never in per-user analysis.eventTimeis optional; omit it and the collector uses arrival time. Send it as RFC 3339 in UTC with three fractional digits:2026-09-06T10:00:00.000Z.- Entity schema references are
vendor/[email protected]. - A body over 1 MB is refused with a 413.
Responses
| Code | Meaning | What your client does |
|---|---|---|
202 accepted | All events stored | Drop the batch. Done. |
202 partially_accepted | Some events refused, by index, with reasons | Drop the batch. Report the rejected ones. Never resend them. |
| 400 | Every event was invalid | Drop the batch. Report. Never retry. |
| 401 | Key unknown, inactive, expired, the wrong kind, or a disallowed origin | Stop. Do not retry, and do not keep buffering against a key that will never work. |
| 413 | Body over 1 MB | Split the batch in half and retry each half. |
| 429 | Rate limit exceeded | Back off. Honour Retry-After if present. |
| 503 | Control plane unreachable; the key was never verified | Retry. |
| 5xx, timeouts, connection failures | No verdict | Retry. |
3. What a good minimal client must implement
Six things. Skip any of them and you will find out which one during an incident.
3.1 Batching
One request per event does not scale, and it is not how the collector is designed to be used. Buffer events and send them in batches.
- Batch size: 50–100 events is a sensible default for a server. Larger batches amortise the request overhead; smaller ones lose less when a batch fails.
- Flush on three triggers: the buffer reaching your batch size, a periodic timer (5–10 seconds is typical), and process shutdown (§3.6).
- Stay under 1 MB. The collector refuses larger bodies with a 413. Either bound your batch by serialised byte count rather than event count, or handle the 413 by splitting the batch in half and retrying each half — which is the more robust option, because it copes with one unexpectedly enormous event.
- Bound the buffer. If delivery is failing and you buffer without a ceiling, you will exhaust memory. Pick a maximum (10,000 events, say) and drop the oldest past it — recent behaviour is usually the more valuable half — while reporting that you did.
3.2 Retry with exponential backoff and jitter
Retry only what can succeed on a retry.
delay = min(cap, base * 2^attempt) * random()with base around 0.5s and cap around 30s. Cap the number of attempts (5 is a good default) and then give up: report the failure and either drop the batch or leave it in the buffer for the next flush, but do not retry forever.
The jitter is not optional. Without a random factor, every client that failed during the same outage retries at the same instants, and the thundering herd re-creates the outage the moment the service comes back. Multiply the delay by a random number in [0, 1) — full jitter — and the same load spreads smoothly.
Honour Retry-After on a 429. The collector knows how long your key needs to wait; your backoff schedule is a guess.
3.3 The never-retry status list
400, 401, 403, 410, 422A response with any of these statuses will never succeed on a retry. The payload is permanently invalid, or the key is permanently unacceptable. Retrying wastes requests, delays every event queued behind the bad batch, and turns a small problem into a stuck pipeline.
Drop the batch and report it loudly enough that a human sees it.
A 401 deserves special handling: it means the key is wrong, not that this batch is wrong. Stop the client entirely rather than continuing to buffer events that can never be delivered — the shipped SDKs call this failing closed.
Rejected events inside a 2xx are also never retried. A 202 partially_accepted lists events that were refused by index, with reasons. Those events are permanently invalid in exactly the same way as a 400. Report them; do not resend them.
3.4 messageId for de-duplication
Delivery is at-least-once. A request can time out after the collector stored the events but before your client saw the response; your retry then sends them a second time.
The collector de-duplicates on messageId. Storage collapses rows with the same one, so a duplicate delivery becomes one row.
The rule that makes this work:
Generate the
messageIdonce, when the event is created, and reuse it on every retry.
Generate it at send time instead — inside the retry loop — and every retry produces a new id, de-duplication cannot fire, and you get duplicates in reporting that no one can distinguish from real ones. A UUID v4 per event is fine. If your events already have a natural unique identity — an order id plus an event name, say — use a deterministic id derived from it, and you get de-duplication across process restarts too.
Send a new messageId for a genuinely new event and the same one when retrying. That is the whole contract.
3.5 Never break the caller
Analytics failing must never fail the request that produced the event.
trackshould not raise. Catch everything at the boundary.trackshould not block on the network. Buffer and return; deliver on a worker.- A slow or dead collector must not become a slow or dead application. Set connect and read timeouts — 10 seconds is a reasonable default — on every request.
- Report failures through a callback or your logger. Never swallow them silently; a pipeline that fails quietly is worse than one that fails loudly.
3.6 Flush before exit
This is the one most people get wrong. Whatever is in your buffer when the process ends is gone. A batching client without a shutdown flush loses up to a full batch every deploy, every restart, every scale-down, and every crash-loop iteration — which in a busy service is a lot of events, and they are silently missing rather than visibly missing.
Hook whatever your language and runtime offer:
| Runtime | Hook |
|---|---|
| Ruby | at_exit { client.flush } |
| PHP (CLI / worker) | register_shutdown_function([$client, 'flush']) |
| PHP (per-request) | Flush at the end of the request — there is no long-lived buffer to lose (§5). |
| .NET | IDisposable / IAsyncDisposable, plus AppDomain.CurrentDomain.ProcessExit |
| Any container | Handle SIGTERM, flush, then exit. Do not rely on SIGKILL giving you time — it gives you none. |
Give the flush a bounded timeout — a couple of seconds — so a dead collector cannot hang your shutdown past your orchestrator's grace period and earn you a SIGKILL, which loses the events anyway.
3.7 Two more, if you can
- Send
X-Selica-Sdkasyourclient/1.0.0. It costs nothing and makes it possible to tell, during an incident, which client is misbehaving. - Refuse a public key. If your client is server-side, reject an
apiKeystarting withpk_at construction. It is almost always a copy-paste from the web snippet, and it will produce confusing 401s later instead of a clear error now.
4. Shell / curl
The simplest possible working call, and the one to reach for when you are diagnosing something.
bash
#!/usr/bin/env bash
set -euo pipefail
COLLECTOR="${TANGENT_COLLECTOR:-https://collect.tangentlens.com/collector}"
KEY="${TANGENT_KEY:?set TANGENT_KEY}"
curl --silent --show-error --fail-with-body \
--max-time 10 \
-X POST "$COLLECTOR/v1/events" \
-H "X-API-Key: $KEY" \
-H "X-Selica-Sdk: shell/1.0.0" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"messageId": "6f1c9a2e-4d3b-4a51-9c88-2b7e10f4a3d9",
"eventType": "plan_upgraded",
"eventTime": "2026-09-06T10:00:00.000Z",
"userId": "user-1042",
"sessionId": "sess-88f2",
"properties": { "from": "starter", "channel": "api" },
"revenue": { "price": 49.00, "quantity": 3, "currency": "USD", "itemId": "team-pro", "revenueType": "upgrade" },
"entities": [
{ "schema": "com.acme/[email protected]", "data": { "id": "acct-42", "tier": "pro" } }
]
}
]
}'Real response, from running exactly this body against a live collector:
json
{"status":"accepted","acceptedCount":1,"rejectedCount":0,"rejected":[],"requestId":"700fb5c0-bfb9-4e6b-a859-6ca6dd3ba5e6","serverReceivedAt":"2026-09-06T17:11:55.023378Z"}requestId is worth logging — quote it when you ask us about a batch. serverReceivedAt is the collector's own clock, which is how you detect skew in your eventTime values.
Seeing a partial rejection
Send one good event and one with a userId under five characters:
bash
curl -sS -X POST "$COLLECTOR/v1/events" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"events":[
{"messageId":"a1","eventType":"ok_event","userId":"user-1042"},
{"messageId":"b2","eventType":"bad_event","userId":"u1"}
]}'Real response (HTTP 202):
json
{"status":"partially_accepted","acceptedCount":1,"rejectedCount":1,"rejected":[{"index":1,"messageId":"b2","errors":["userId must be at least 5 characters"]}],"requestId":"cd788497-d4c6-42ad-9f4c-f8daa16efae2","serverReceivedAt":"2026-09-06T10:26:20.727449Z"}The batch succeeded. Event 1 did not, and never will — do not resend it.
When every event is invalid
bash
curl -sS -w '\n[%{http_code}]\n' -X POST "$COLLECTOR/v1/events" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"events":[{"messageId":"z1","eventType":"bad","userId":"u1"}]}'Real response:
json
{"timestamp":"2026-09-06T10:41:37.775409Z","status":400,"error":"Bad Request","message":"No event in the batch was valid","rejected":[{"index":0,"messageId":"z1","errors":["userId must be at least 5 characters"]}],"path":"/collector/v1/events"}
[400]400 is in the never-retry list.
Checking a key
bash
curl -sS -X GET "$COLLECTOR/v1/key/validate" -H "X-API-Key: $KEY"Real response (trimmed — the full body also carries the tenant's enabled feature flags):
json
{"appId":"f4b7d6aa-a54f-43e0-b805-e2535b25d735","environment":"prod","keyType":"PUBLIC","valid":true}A bad key returns 401 with an empty body:
bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST "$COLLECTOR/v1/events" \
-H "X-API-Key: not_a_real_key" -H "Content-Type: application/json" \
-d '{"events":[{"eventType":"x","userId":"user-1042"}]}'
# 401Make this the first thing you run when nothing is arriving.
Sending a file of events
Useful for backfills and for replaying a captured payload:
bash
curl -sS -X POST "$COLLECTOR/v1/events" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
--data @events.jsonShell is not a good place for a real client — there is no buffering, no retry and no de-duplication, and one process per event will exhaust your rate limit quickly. Use it for diagnosis and one-off backfills.
5. Ruby
Standard library only — no gems. This is a complete, production-shaped client implementing everything in §3.
ruby
# frozen_string_literal: true
#
# tangent.rb — a minimal Tangent client. Standard library only.
require "json"
require "net/http"
require "securerandom"
require "uri"
require "time"
module Tangent
# Statuses the collector will never accept on a retry. Retrying them wastes
# requests and delays every other event queued behind them.
NEVER_RETRY = [400, 401, 403, 410, 422].freeze
class Client
SDK = "ruby-minimal/1.0.0"
def initialize(api_key:,
endpoint: "https://collect.tangentlens.com/collector",
batch_size: 50,
flush_interval: 5,
max_retries: 5,
timeout: 10,
max_queue: 10_000,
on_error: nil)
@api_key = api_key
@uri = URI.join(endpoint.chomp("/") + "/", "v1/events")
@batch_size = batch_size
@max_retries = max_retries
@timeout = timeout
@max_queue = max_queue
@on_error = on_error || ->(e, ctx) { warn "tangent #{ctx}: #{e}" }
@queue = []
@mutex = Mutex.new
@worker = Thread.new do
loop do
sleep flush_interval
begin
flush
rescue => e
@on_error.call(e, "worker")
end
end
end
@worker.abort_on_exception = false
# Flushing before exit is not optional: whatever is still in the queue
# when the process ends is gone.
at_exit { flush }
end
# Never raises, never blocks on the network.
def track(event_type, user_id: nil, device_id: nil, session_id: nil,
properties: nil, user_properties: nil, groups: nil,
revenue: nil, entities: nil, event_time: nil)
event = {
# The de-duplication key. Generated ONCE, here, and reused on every
# retry, so a batch delivered twice collapses to one row server-side.
"messageId" => SecureRandom.uuid,
"eventType" => event_type,
"eventTime" => (event_time || Time.now).utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
}
event["userId"] = user_id if user_id
event["deviceId"] = device_id if device_id
event["sessionId"] = session_id if session_id
event["properties"] = properties if properties && !properties.empty?
event["userProperties"] = user_properties if user_properties && !user_properties.empty?
event["groups"] = groups if groups && !groups.empty?
event["revenue"] = revenue if revenue
event["entities"] = entities if entities && !entities.empty?
full = @mutex.synchronize do
# Bound the buffer. Dropping the oldest keeps recent behaviour, which is
# usually the more valuable half when a backlog builds.
@queue.shift while @queue.size >= @max_queue
@queue << event
@queue.size >= @batch_size
end
flush if full
nil
end
def identify(user_id, traits = {})
track("$identify", user_id: user_id,
user_properties: traits.empty? ? nil : { "$set" => traits })
end
# Sends everything queued now. Returns the number of events handed over.
def flush
accepted = 0
loop do
batch = @mutex.synchronize { @queue.shift(@batch_size) }
break if batch.empty?
unless send_batch(batch)
# Failed for a retryable reason: put it back at the FRONT so ordering
# survives, and stop until the next flush.
@mutex.synchronize { @queue.unshift(*batch) }
break
end
accepted += batch.size
end
accepted
end
private
def send_batch(batch)
body = JSON.generate({ "events" => batch })
@max_retries.times do |attempt|
begin
response = post(body)
code = response.code.to_i
if code >= 200 && code < 300
report_rejects(response.body)
return true
end
if NEVER_RETRY.include?(code)
# Permanently invalid, or permanently unauthorised. Drop and report;
# retrying can only ever fail again.
@on_error.call(RuntimeError.new("HTTP #{code}: #{response.body.to_s[0, 300]}"), "rejected")
return true
end
if code == 429
# The collector knows how long to wait; our schedule is a guess.
wait = response["Retry-After"].to_f
sleep(wait > 0 ? wait : backoff(attempt))
next
end
# 413, 5xx and anything else: transient.
sleep backoff(attempt)
rescue => e
# A timeout or a connection failure is not a verdict — retry it.
@on_error.call(e, "transport") if attempt + 1 == @max_retries
sleep backoff(attempt) unless attempt + 1 == @max_retries
end
end
false
end
def post(body)
http = Net::HTTP.new(@uri.host, @uri.port)
http.use_ssl = (@uri.scheme == "https")
http.open_timeout = @timeout
http.read_timeout = @timeout
request = Net::HTTP::Post.new(@uri.request_uri)
request["Content-Type"] = "application/json"
request["X-API-Key"] = @api_key
request["X-Selica-Sdk"] = SDK
request.body = body
http.request(request)
end
# Exponential backoff with full jitter. Without the jitter, every client that
# failed together retries together and re-creates the outage.
def backoff(attempt)
[30.0, 0.5 * (2**attempt)].min * rand
end
# A 202 can still refuse individual events. They are permanently invalid:
# report them so someone fixes the payload, and never resend them.
def report_rejects(body)
parsed = JSON.parse(body.to_s)
rejected = parsed["rejected"]
return if rejected.nil? || rejected.empty?
rejected.each do |r|
@on_error.call(
RuntimeError.new("event #{r["index"]} (#{r["messageId"]}) refused: #{Array(r["errors"]).join("; ")}"),
"rejected",
)
end
rescue JSON::ParserError
nil
end
end
endUsing it:
ruby
require_relative "tangent"
client = Tangent::Client.new(
api_key: ENV.fetch("TANGENT_KEY"),
endpoint: "https://collect.tangentlens.com/collector",
)
client.identify("user-1042", "plan" => "pro")
client.track("plan_upgraded",
user_id: "user-1042",
session_id: "sess-88f2",
properties: { "from" => "starter", "channel" => "api" },
revenue: { "price" => 49.00, "quantity" => 3, "currency" => "USD",
"itemId" => "team-pro", "revenueType" => "upgrade" },
entities: [{ "schema" => "com.acme/[email protected]",
"data" => { "id" => "acct-42", "tier" => "pro" } }])
# An event the collector will refuse: userId is under 5 characters.
client.track("bad_event", user_id: "u1")
puts "handed over: #{client.flush}"Real output, running this against a live collector (with at_exit also flushing):
tangent rejected: event 2 (9bd660e6-bca4-429c-bccb-0c1f82569790) refused: userId must be at least 5 characters
handed over: 3Three events went in the batch, the collector accepted two and refused one by index, and the client reported the refusal without resending it. That is the behaviour §3.3 describes, working.
Rails
Put the client in an initializer so there is one per process:
ruby
# config/initializers/tangent.rb
require Rails.root.join("lib", "tangent")
TANGENT = Tangent::Client.new(
api_key: ENV.fetch("TANGENT_KEY"),
endpoint: ENV.fetch("TANGENT_COLLECTOR", "https://collect.tangentlens.com/collector"),
on_error: ->(e, ctx) { Rails.logger.warn("tangent #{ctx}: #{e}") },
)ruby
class ProjectsController < ApplicationController
def create
project = Project.create!(project_params)
TANGENT.track("project_created",
user_id: current_user.id.to_s,
properties: { "template" => project.template, "projectId" => project.id.to_s })
render json: project
end
endForking servers need care. Puma in clustered mode and Unicorn fork after the initializer runs, and a Ruby Thread does not survive a fork — the child gets the queue's contents but no worker thread, so nothing is ever flushed until at_exit. Recreate the client in the fork hook:
ruby
# config/puma.rb
on_worker_boot do
Object.send(:remove_const, :TANGENT) if defined?(TANGENT)
TANGENT = Tangent::Client.new(api_key: ENV.fetch("TANGENT_KEY"))
end6. PHP
PHP's process model changes the right design. In a classic per-request deployment (mod_php, PHP-FPM) there is no process that outlives the request, so a background flush thread is not an option and a long-lived buffer is not either. Buffer for the duration of the request and flush at the end — which register_shutdown_function does for you, including on a fatal error.
php
<?php
declare(strict_types=1);
namespace Tangent;
/**
* A minimal Tangent client. cURL and json extensions only — no Composer packages.
*
* In a per-request deployment (PHP-FPM, mod_php) the buffer lives for one request
* and is flushed by the registered shutdown function. In a long-running worker
* (Swoole, RoadRunner, a CLI daemon) call flush() on your own interval as well.
*/
final class Client
{
private const SDK = 'php-minimal/1.0.0';
/** Statuses the collector will never accept on a retry. */
private const NEVER_RETRY = [400, 401, 403, 410, 422];
private string $apiKey;
private string $url;
private int $batchSize;
private int $maxRetries;
private int $timeout;
private int $maxQueue;
/** @var callable(\Throwable, string): void */
private $onError;
/** @var array<int, array<string, mixed>> */
private array $queue = [];
public function __construct(
string $apiKey,
string $endpoint = 'https://collect.tangentlens.com/collector',
int $batchSize = 50,
int $maxRetries = 5,
int $timeout = 10,
int $maxQueue = 10000,
?callable $onError = null
) {
if (str_starts_with($apiKey, 'pk_')) {
throw new \InvalidArgumentException('server-side code must use a secret key (sk_…)');
}
$this->apiKey = $apiKey;
$this->url = rtrim($endpoint, '/') . '/v1/events';
$this->batchSize = $batchSize;
$this->maxRetries = $maxRetries;
$this->timeout = $timeout;
$this->maxQueue = $maxQueue;
$this->onError = $onError ?? static function (\Throwable $e, string $ctx): void {
error_log("tangent {$ctx}: {$e->getMessage()}");
};
// Flushing before exit is not optional: whatever is still buffered when
// the request ends is gone. This runs on a fatal error too.
register_shutdown_function([$this, 'flush']);
}
/**
* Queues an event. Never throws.
*
* @param array<string, mixed> $fields Any of: userId, deviceId, sessionId,
* properties, userProperties, groups,
* revenue, entities, eventTime.
*/
public function track(string $eventType, array $fields = []): void
{
try {
$event = [
// The de-duplication key. Generated ONCE, here, and reused on
// every retry, so a duplicate delivery collapses to one row.
'messageId' => $this->uuid4(),
'eventType' => $eventType,
'eventTime' => $fields['eventTime']
?? (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z'),
];
foreach (['userId', 'deviceId', 'sessionId', 'revenue'] as $key) {
if (isset($fields[$key])) {
$event[$key] = $fields[$key];
}
}
foreach (['properties', 'userProperties', 'groups', 'entities'] as $key) {
if (!empty($fields[$key])) {
$event[$key] = $fields[$key];
}
}
// Bound the buffer; drop the oldest past the ceiling.
while (count($this->queue) >= $this->maxQueue) {
array_shift($this->queue);
}
$this->queue[] = $event;
if (count($this->queue) >= $this->batchSize) {
$this->flush();
}
} catch (\Throwable $e) {
// Analytics must never break the request that produced the event.
($this->onError)($e, 'track');
}
}
/** @param array<string, mixed> $traits */
public function identify(string $userId, array $traits = []): void
{
$this->track('$identify', [
'userId' => $userId,
'userProperties' => $traits === [] ? [] : ['$set' => $traits],
]);
}
/** Sends everything buffered now. Returns the number of events handed over. */
public function flush(): int
{
$handed = 0;
while ($this->queue !== []) {
$batch = array_splice($this->queue, 0, $this->batchSize);
if (!$this->sendBatch($batch)) {
// Retryable failure: put it back at the FRONT so ordering
// survives, and stop until the next flush.
array_splice($this->queue, 0, 0, $batch);
break;
}
$handed += count($batch);
}
return $handed;
}
/** @param array<int, array<string, mixed>> $batch */
private function sendBatch(array $batch): bool
{
$body = json_encode(['events' => $batch], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($body === false) {
($this->onError)(new \RuntimeException('could not encode batch: ' . json_last_error_msg()), 'encode');
return true; // Unencodable is permanently invalid. Drop it.
}
// A body over 1 MB is refused with a 413. Split rather than send it.
if (strlen($body) > 1_000_000 && count($batch) > 1) {
$half = (int) (count($batch) / 2);
return $this->sendBatch(array_slice($batch, 0, $half))
&& $this->sendBatch(array_slice($batch, $half));
}
for ($attempt = 0; $attempt < $this->maxRetries; $attempt++) {
[$status, $response, $retryAfter, $error] = $this->post($body);
if ($error !== null) {
// A timeout or connection failure is not a verdict — retry it.
if ($attempt + 1 === $this->maxRetries) {
($this->onError)(new \RuntimeException($error), 'transport');
return false;
}
usleep((int) ($this->backoff($attempt) * 1_000_000));
continue;
}
if ($status >= 200 && $status < 300) {
$this->reportRejects($response);
return true;
}
if (in_array($status, self::NEVER_RETRY, true)) {
// Permanently invalid or permanently unauthorised. Drop and report.
($this->onError)(
new \RuntimeException("HTTP {$status}: " . substr($response, 0, 300)),
'rejected'
);
return true;
}
if ($attempt + 1 === $this->maxRetries) {
($this->onError)(new \RuntimeException("gave up after {$this->maxRetries} attempts (HTTP {$status})"), 'request');
return false;
}
// Honour Retry-After on a 429; the collector knows better than we do.
$wait = ($status === 429 && $retryAfter > 0) ? $retryAfter : $this->backoff($attempt);
usleep((int) ($wait * 1_000_000));
}
return false;
}
/** @return array{0:int, 1:string, 2:float, 3:?string} status, body, retryAfter, error */
private function post(string $body): array
{
$ch = curl_init($this->url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_CONNECTTIMEOUT => $this->timeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . $this->apiKey,
'X-Selica-Sdk: ' . self::SDK,
],
]);
$raw = curl_exec($ch);
if ($raw === false) {
$error = curl_error($ch);
curl_close($ch);
return [0, '', 0.0, $error];
}
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$headers = substr((string) $raw, 0, $headerSize);
$payload = substr((string) $raw, $headerSize);
$retryAfter = 0.0;
if (preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $m) === 1) {
$retryAfter = (float) $m[1];
}
return [$status, $payload, $retryAfter, null];
}
/** Exponential backoff with full jitter, in seconds. */
private function backoff(int $attempt): float
{
return min(30.0, 0.5 * (2 ** $attempt)) * (mt_rand() / mt_getrandmax());
}
/**
* A 202 can still refuse individual events. They are permanently invalid:
* report them so someone fixes the payload, and never resend them.
*/
private function reportRejects(string $response): void
{
$parsed = json_decode($response, true);
if (!is_array($parsed) || empty($parsed['rejected'])) {
return;
}
foreach ($parsed['rejected'] as $rejected) {
$errors = implode('; ', (array) ($rejected['errors'] ?? []));
($this->onError)(
new \RuntimeException(
"event {$rejected['index']} ({$rejected['messageId']}) refused: {$errors}"
),
'rejected'
);
}
}
private function uuid4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
}Using it:
php
<?php
require __DIR__ . '/Tangent/Client.php';
$tangent = new \Tangent\Client(getenv('TANGENT_KEY') ?: '');
$tangent->identify('user-1042', ['plan' => 'pro']);
$tangent->track('plan_upgraded', [
'userId' => 'user-1042',
'sessionId' => 'sess-88f2',
'properties' => ['from' => 'starter', 'channel' => 'api'],
'revenue' => [
'price' => 49.00,
'quantity' => 3,
'currency' => 'USD',
'itemId' => 'team-pro',
'revenueType' => 'upgrade',
],
'entities' => [
['schema' => 'com.acme/[email protected]', 'data' => ['id' => 'acct-42', 'tier' => 'pro']],
],
]);
// No explicit flush needed — the registered shutdown function handles it.Requires PHP 8.0 or later (str_starts_with, constructor promotion is not used but named arguments and union types in the docblocks assume 8.x). On PHP 7.4, replace str_starts_with with strpos($apiKey, 'pk_') === 0 and remove the 1_000_000 numeric separator.
Long-running workers. Under Swoole, RoadRunner or a CLI daemon the process outlives the request, so register_shutdown_function fires only at the very end. Call flush() on your own timer as well — a 5-second tick — or events will sit buffered for the life of the worker.
7. C#
Idiomatic .NET: a single HttpClient, a Channel as the buffer, a background flush loop, and IAsyncDisposable for the shutdown flush. Targets .NET 6 or later with nullable reference types enabled.
csharp
// TangentClient.cs
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
namespace Tangent;
public sealed record TangentOptions
{
public required string ApiKey { get; init; }
public string Endpoint { get; init; } = "https://collect.tangentlens.com/collector";
public int BatchSize { get; init; } = 50;
public TimeSpan FlushInterval { get; init; } = TimeSpan.FromSeconds(5);
public int MaxRetries { get; init; } = 5;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
public int MaxQueue { get; init; } = 10_000;
public Action<Exception, string>? OnError { get; init; }
}
public sealed class TangentClient : IAsyncDisposable
{
private const string Sdk = "csharp-minimal/1.0.0";
/// <summary>Statuses the collector will never accept on a retry.</summary>
private static readonly HashSet<int> NeverRetry = new() { 400, 401, 403, 410, 422 };
private static readonly JsonSerializerOptions Json = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly TangentOptions _options;
private readonly HttpClient _http;
private readonly Channel<Dictionary<string, object?>> _queue;
private readonly CancellationTokenSource _shutdown = new();
private readonly Task _worker;
private readonly Action<Exception, string> _onError;
public TangentClient(TangentOptions options, HttpClient? http = null)
{
if (options.ApiKey.StartsWith("pk_", StringComparison.Ordinal))
throw new ArgumentException("server-side code must use a secret key (sk_…)", nameof(options));
_options = options;
_onError = options.OnError ?? ((e, ctx) => Console.Error.WriteLine($"tangent {ctx}: {e.Message}"));
_http = http ?? new HttpClient();
_http.Timeout = options.Timeout;
_http.DefaultRequestHeaders.Add("X-API-Key", options.ApiKey);
_http.DefaultRequestHeaders.Add("X-Selica-Sdk", Sdk);
// A bounded channel that drops the oldest when full: the buffer can never
// exhaust memory while delivery is failing.
_queue = Channel.CreateBounded<Dictionary<string, object?>>(
new BoundedChannelOptions(options.MaxQueue) { FullMode = BoundedChannelFullMode.DropOldest });
_worker = Task.Run(() => RunAsync(_shutdown.Token));
}
/// <summary>Queues an event. Never throws and never blocks on the network.</summary>
public void Track(
string eventType,
string? userId = null,
string? deviceId = null,
string? sessionId = null,
IReadOnlyDictionary<string, object?>? properties = null,
IReadOnlyDictionary<string, object?>? userProperties = null,
IReadOnlyDictionary<string, object?>? groups = null,
IReadOnlyDictionary<string, object?>? revenue = null,
IReadOnlyList<object>? entities = null,
DateTimeOffset? eventTime = null)
{
try
{
var e = new Dictionary<string, object?>
{
// The de-duplication key. Generated ONCE, here, and reused on every
// retry, so a duplicate delivery collapses to one row server-side.
["messageId"] = Guid.NewGuid().ToString(),
["eventType"] = eventType,
["eventTime"] = (eventTime ?? DateTimeOffset.UtcNow)
.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"),
};
if (userId is not null) e["userId"] = userId;
if (deviceId is not null) e["deviceId"] = deviceId;
if (sessionId is not null) e["sessionId"] = sessionId;
if (properties is { Count: > 0 }) e["properties"] = properties;
if (userProperties is { Count: > 0 }) e["userProperties"] = userProperties;
if (groups is { Count: > 0 }) e["groups"] = groups;
if (revenue is { Count: > 0 }) e["revenue"] = revenue;
if (entities is { Count: > 0 }) e["entities"] = entities;
// DropOldest means this only returns false once the channel is completed.
_queue.Writer.TryWrite(e);
}
catch (Exception ex)
{
// Analytics must never break the caller.
_onError(ex, "track");
}
}
public void Identify(string userId, IReadOnlyDictionary<string, object?>? traits = null) =>
Track("$identify", userId: userId,
userProperties: traits is { Count: > 0 }
? new Dictionary<string, object?> { ["$set"] = traits }
: null);
/// <summary>Sends everything buffered now. Returns the number of events handed over.</summary>
public async Task<int> FlushAsync(CancellationToken cancellationToken = default)
{
var handed = 0;
var batch = new List<Dictionary<string, object?>>(_options.BatchSize);
while (_queue.Reader.TryRead(out var e))
{
batch.Add(e);
if (batch.Count < _options.BatchSize) continue;
if (!await SendAsync(batch, cancellationToken).ConfigureAwait(false)) return handed;
handed += batch.Count;
batch.Clear();
}
if (batch.Count > 0 && await SendAsync(batch, cancellationToken).ConfigureAwait(false))
handed += batch.Count;
return handed;
}
private async Task RunAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(_options.FlushInterval);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
try { await FlushAsync(cancellationToken).ConfigureAwait(false); }
catch (Exception e) { _onError(e, "worker"); }
}
}
catch (OperationCanceledException) { /* shutting down */ }
}
private async Task<bool> SendAsync(
List<Dictionary<string, object?>> batch, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { events = batch }, Json);
// A body over 1 MB is refused with a 413. Split rather than send it.
if (Encoding.UTF8.GetByteCount(payload) > 1_000_000 && batch.Count > 1)
{
var half = batch.Count / 2;
return await SendAsync(batch.GetRange(0, half), cancellationToken).ConfigureAwait(false)
&& await SendAsync(batch.GetRange(half, batch.Count - half), cancellationToken).ConfigureAwait(false);
}
for (var attempt = 0; attempt < _options.MaxRetries; attempt++)
{
HttpResponseMessage response;
try
{
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
response = await _http
.PostAsync($"{_options.Endpoint.TrimEnd('/')}/v1/events", content, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
// A timeout or a connection failure is not a verdict — retry it.
if (attempt + 1 == _options.MaxRetries) { _onError(e, "transport"); return false; }
await Task.Delay(Backoff(attempt), cancellationToken).ConfigureAwait(false);
continue;
}
using (response)
{
var status = (int)response.StatusCode;
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
ReportRejects(body);
return true;
}
if (NeverRetry.Contains(status))
{
// Permanently invalid or permanently unauthorised. Drop and report.
_onError(new HttpRequestException(
$"HTTP {status}: {body[..Math.Min(300, body.Length)]}"), "rejected");
return true;
}
if (attempt + 1 == _options.MaxRetries)
{
_onError(new HttpRequestException(
$"gave up after {_options.MaxRetries} attempts (HTTP {status})"), "request");
return false;
}
// Honour Retry-After on a 429; the collector knows better than we do.
var wait = status == (int)HttpStatusCode.TooManyRequests
? RetryAfter(response) ?? Backoff(attempt)
: Backoff(attempt);
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
}
}
return false;
}
private static TimeSpan? RetryAfter(HttpResponseMessage response)
{
RetryConditionHeaderValue? header = response.Headers.RetryAfter;
if (header?.Delta is { } delta) return delta;
if (header?.Date is { } date) return date - DateTimeOffset.UtcNow;
return null;
}
/// <summary>Exponential backoff with full jitter — without it, every client that
/// failed together retries together and re-creates the outage.</summary>
private static TimeSpan Backoff(int attempt) =>
TimeSpan.FromSeconds(Math.Min(30.0, 0.5 * Math.Pow(2, attempt)) * Random.Shared.NextDouble());
/// <summary>A 202 can still refuse individual events. They are permanently invalid:
/// report them so someone fixes the payload, and never resend them.</summary>
private void ReportRejects(string body)
{
try
{
using var document = JsonDocument.Parse(body);
if (!document.RootElement.TryGetProperty("rejected", out var rejected)) return;
foreach (var item in rejected.EnumerateArray())
{
var index = item.TryGetProperty("index", out var i) ? i.GetInt32() : -1;
var messageId = item.TryGetProperty("messageId", out var m) ? m.GetString() : "?";
var errors = item.TryGetProperty("errors", out var errs)
? string.Join("; ", errs.EnumerateArray().Select(x => x.GetString()))
: "?";
_onError(new InvalidOperationException(
$"event {index} ({messageId}) refused: {errors}"), "rejected");
}
}
catch (JsonException) { /* not JSON; nothing to report */ }
}
/// <summary>Flushing before exit is not optional: whatever is still buffered
/// when the process ends is gone.</summary>
public async ValueTask DisposeAsync()
{
_shutdown.Cancel();
_queue.Writer.TryComplete();
try { await _worker.ConfigureAwait(false); }
catch (OperationCanceledException) { /* expected */ }
// Bound the final flush so a dead collector cannot hang shutdown past the
// orchestrator's grace period — a SIGKILL loses the events anyway.
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try { await FlushAsync(deadline.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { _onError(new TimeoutException("flush timed out on shutdown"), "shutdown"); }
_shutdown.Dispose();
_http.Dispose();
}
}Using it in ASP.NET Core, where the host's graceful shutdown disposes the singleton for you:
csharp
// Program.cs
using Tangent;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(sp => new TangentClient(new TangentOptions
{
ApiKey = builder.Configuration["Tangent:ApiKey"]!,
Endpoint = builder.Configuration["Tangent:Endpoint"] ?? "https://collect.tangentlens.com/collector",
OnError = (e, ctx) => sp.GetRequiredService<ILogger<TangentClient>>().LogWarning(e, "tangent {Context}", ctx),
}));
var app = builder.Build();
app.MapPost("/upgrades", (Upgrade upgrade, TangentClient tangent) =>
{
// … apply the plan change …
tangent.Track("plan_upgraded",
userId: upgrade.UserId,
properties: new Dictionary<string, object?>
{
["plan"] = upgrade.Plan,
["seats"] = upgrade.Seats,
},
revenue: new Dictionary<string, object?>
{
["price"] = upgrade.SeatPrice,
["quantity"] = upgrade.Seats,
["currency"] = "USD",
["itemId"] = upgrade.Plan,
});
return Results.Ok(upgrade);
});
app.Run();
public record Upgrade(string UserId, string Plan, int Seats, decimal SeatPrice);AddSingleton matters: one client per process, so there is one buffer and one worker. IAsyncDisposable is honoured by the ASP.NET Core host on graceful shutdown, which is what runs the final flush.
In a console application, dispose explicitly:
csharp
await using var tangent = new TangentClient(new TangentOptions { ApiKey = key });
tangent.Track("job_completed", userId: "user-1042");
// DisposeAsync flushes on the way out of the using scope.8. Language-agnostic checklist
Before you call your client done:
- [ ] Events are batched, not sent one per request.
- [ ] Batches are bounded to stay under 1 MB, or a 413 splits and retries.
- [ ] The buffer has a ceiling, and dropping is reported rather than silent.
- [ ] Retries use exponential backoff with jitter and a cap on attempts.
- [ ]
400, 401, 403, 410, 422are never retried. - [ ] Events listed in a 2xx
rejectedarray are reported and never resent. - [ ] A 401 stops the client rather than buffering against a dead key.
- [ ]
Retry-Afteris honoured on a 429. - [ ] Every request has a connect and read timeout.
- [ ]
messageIdis generated once per event and reused on retry. - [ ]
tracknever throws and never blocks on the network. - [ ] The buffer is flushed before the process exits, with a bounded timeout.
- [ ] Failures are reported, never swallowed.
- [ ] The key is a secret key, loaded from the environment, never committed.
- [ ]
X-Selica-Sdkidentifies your client.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing arrives, no error | Your buffer is never flushed — no timer, no shutdown hook | Add both. See §3.6. |
| 401 on every request | Wrong key, an expired or deactivated key, a key for a different app or environment | curl -H "X-API-Key: $KEY" "$COLLECTOR/v1/key/validate". A 200 returns appId, environment and keyType. |
| 401 only from your server, while the browser works | You are using a pk_ key and sending an Origin header, or an sk_ key with an Origin header — secret keys are rejected when an Origin is present | Use a secret key from a server and do not send Origin. |
| 429 | The key's rate limit is exceeded | Increase the batch size so the same events cost fewer requests, honour Retry-After, and check for a loop tracking one event per row of something. |
| 413 | The body exceeded 1 MB | Split the batch. See §3.1. |
| Events accepted but missing in reporting | They were in the rejected array of a 202 you did not read | Log the whole response body on a 202 until you are sure. See §4. |
| Duplicate events | A new messageId generated on each retry attempt | Generate it once, at event creation. See §3.4. |
| Events arrive with wrong timestamps | Your server's clock is skewed, or eventTime is not UTC | Send RFC 3339 in UTC with three fractional digits, or omit eventTime entirely and let the collector use arrival time. Compare against serverReceivedAt in the response. |
| Events out of order | Expected. Ordering is not guaranteed across batches or retries | Order by eventTime in reporting, not by arrival. |
| The pipeline stalls behind one bad batch | You are retrying a status in the never-retry list | See §3.3. |
| A deploy loses events every time | No shutdown flush, or the flush is unbounded and gets SIGKILLed | See §3.6. |
Verification status
- The curl examples in §4 were executed against a live collector. Every response block marked "Real response" is the verbatim body that came back — the 202
accepted, the 202partially_acceptedwith its per-index rejection, the 400 withNo event in the batch was valid, thekey/validatebody, and the 401 status code. - The Ruby client in §5 was executed against the same live collector. The output block is its real output: three events sent, the invalid one refused by index and reported, no resend. The Rails and Puma snippets were not run.
- The PHP client in §6 was not run — no PHP runtime was available on the machine this page was written on. It is carefully reviewed against the PHP 8 standard library, but it is unverified.
- The C# client in §7 was not run — no .NET SDK was available. Same caveat: reviewed against the .NET 6+ API surface, unverified.
Compile and run the PHP and C# clients before you rely on them, and correct this page when you do.