ارسال رویداد از سرور به سرور
برای چیزی که فقط بکاند شما از آن مطمئن است: پرداخت موفق، ارسال سفارش، لغو اشتراک.
دو آدرس رویداد میگیرند. روی دو هاست جدا هستند، دو نوع کلید میخواهند، و فقط یکی از آن دو تکراریها را حذف میکند. انتخاب بین این دو سلیقهای نیست: تعیین میکند که یک خرید وقتی دوباره فرستاده شد، یک بار شمرده شود یا دو بار.
دو در، و یکی نیستند
| کلکتور | API مدیریت | |
|---|---|---|
| آدرس | https://in.segmentic.net/v1/batch | https://api.segmentic.net/v1/events |
| کلید | کلید نوشتن، wk_seg_... | کلید API، sk_seg_... |
| دسترسی لازم | ندارد. خود کلید نوشتن مجوز است | profile.write |
| نام آرایه در بدنه | batch | events |
| پاسخ موفق | 200 | 202 |
حذف تکراری با message_id | دارد | ندارد |
| برگرداندن هشدارها | دارد | ندارد، دور ریخته میشوند |
| سقف عقببردن زمان | نگهداشت رویداد همان حساب | ثابت، ۳۰ روز |
context و sent_at در سطح بسته | دارد | چنین فیلدهایی ندارد |
| بیشترین تعداد آیتم | ۵۰۰ | ۵۰۰ |
| بیشترین حجم بدنه | ۵ مگابایت | ۸ مگابایت |
| مصرف از بودجهٔ درخواست | ندارد | دارد، ۵ واحد از ۶۰۰ در دقیقه |
کلید نوشتن از روی عمد عمومی است. داخل جاوااسکریپت خود مشتری و داخل اپ اندروید او منتشر میشود و کل پلتفرم روی این فرض ساخته شده که هر کسی میتواند آن را بخواند. یک کلید wk_ در باندل عمومی یعنی همهچیز طبق طراحی کار میکند؛ یک کلید sk_ در همان جا یعنی حادثهٔ امنیتی. پس استفاده از کلید نوشتن در بکاند شما افت امنیتی نیست: همان کلید است که همان کار را میکند، فقط از یک ماشین بهجای یک گوشی.
کلید API نقطهٔ مقابل است. نقش دارد، میتواند سگمنت بسازد و کمپین بفرستد، و هرگز نباید از سرورهای شما بیرون برود. دادن آن به کلکتور فایدهای ندارد، و دادن کلید نوشتن به API مدیریت با یک کد اختصاصی رد میشود تا اشتباه همان لحظه دیده شود:
{
"error": {
"code": "write_key_rejected",
"message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
}
}
بکاند شما کدام را باید بردارد
POST /v1/batch کلکتور را.
دلیلش حذف تکراری است. خط لولهٔ سفارش شما دوباره خواهد فرستاد. یک تایماوت روی لودبالانسر، یک ریدیپلوی وسط درخواست، یک ورکر که بعد از POST و قبل از علامتزدن ردیف کرش میکند: هر کدام به یک جا میرسند، به فرستادن دوبارهٔ همان بسته. کلکتور message_id را با SET NX در ردیس و پنجرهٔ ۴۸ ساعته ثبت میکند، پس تحویل دوم با 200 جواب میگیرد و هرگز به انبار داده نمیرسد. POST /v1/events این کار را نمیکند. پاکتها را مستقیم به صف میدهد. یک بستهٔ صد سفارشی که دوباره فرستاده شود میشود دویست سفارش، و اولین نشانهاش یک عدد درآمد است که هیچکس نمیتواند با حسابداری تطبیقش بدهد.
دلیل دوم، پنجرهٔ عقببردن زمان است. کلکتور نگهداشت رویداد همان حساب را میخواند و اجازه میدهد تا همانقدر عقب بروید. POST /v1/events این گزینه را پر نمیکند، پس همان پیشفرض ثابت ۳۰ روزه اعمال میشود و هرچه قدیمیتر باشد، بیصدا درست به ۳۰ روز قبل منتقل میشود. در بخش زمان رویداد توضیح داده شده.
سراغ POST /v1/events بروید وقتی سرویس شما همین حالا یک کلید API دارد و اضافهکردن یک کلید دوم به استقرارتان دردسر بزرگتری است، یا وقتی فراخوانکننده یک ایجنت است که همین حالا برای سگمنت و گزارش با api.segmentic.net حرف میزند. در این حالت بپذیرید که مسئولیت دوبارهنفرستادن یک رویداد با شماست.
هر دو در به یک خط لوله و یک جدول در انبار داده میرسند. هیچ چیزی در پاییندست نمیتواند بگوید رویداد از کدام در آمده، جز اینکه رویدادهای POST /v1/events مقدار app_id برابر 0 دارند و هیچ آیپی و User-Agent همراهشان نیست.
کلکتور: POST /v1/batch
کلید در Authorization: Bearer میآید. دو شکل دیگر هم پذیرفته میشود، چون یک بیکن مرورگری نمیتواند هدر بگذارد: X-Segmentic-Key: wk_seg_... و ?write_key=wk_seg_.... از سمت سرور همان هدر را بگذارید. فقط رشتهٔ دقیق Bearer (با B بزرگ و یک فاصله) از Authorization جدا میشود؛ هر طرح دیگری از این بررسی رد میشود، میرود سراغ دو شکل بعدی، و در نهایت با «کلید نیست» شکست میخورد.
هر آیتم type خودش را دارد. روی آدرسهای تکرویدادی (/v1/track و /v1/identify و بقیه) مسیر تعیینکنندهٔ نوع است و type داخل بدنه نادیده گرفته میشود، ولی روی /v1/batch مسیری برای خواندن نوع وجود ندارد، پس آیتمی که type نداشته باشد یا مقدار ناشناخته بفرستد رد میشود. پنج مقدار مجاز: track، identify، alias، page، screen.
curl -sS https://in.segmentic.net/v1/batch \
-H "Authorization: Bearer wk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"context": { "locale": "fa-IR" },
"batch": [
{
"type": "track",
"message_id": "order-8821-completed",
"event": "order_completed",
"user_id": "u_44120",
"timestamp": "2026-08-07T09:12:41Z",
"properties": {
"order_id": "8821",
"revenue": 4800000,
"currency": "IRR",
"city": "تهران"
}
},
{
"type": "identify",
"message_id": "profile-44120-v7",
"user_id": "u_44120",
"traits": { "phone": "09123456789", "city": "تهران" }
}
]
}'
{ "status": "ok", "accepted": 2 }
context و sent_at سطح بسته روی هر آیتمی که مقدار خودش را نداشته باشد کپی میشوند، و هرگز روی مقداری که خودش داشته نوشته نمیشوند. این برای آن ساخته شده که یک SDK موبایل مدل دستگاه را یک بار برای پنجاه رویداد بفرستد، و از سمت سرور هم برای یک context.campaign مشترک به همان اندازه بهدرد میخورد.
فیلدهای هر آیتم:
| فیلد | نوع | اجباری | توضیح |
|---|---|---|---|
type | رشته | روی batch بله | یکی از track، identify، alias، page، screen |
message_id | رشته | نه، ولی بفرستید | حداکثر ۲۵۶ بایت. اگر نباشد ساخته میشود و هشدار میگیرید |
event | رشته | وقتی type برابر track است، بله | حداکثر ۱۲۸ بایت بعد از نرمالسازی |
user_id | رشته | یکی از user_id یا anonymous_id | حداکثر ۲۵۶ بایت |
anonymous_id | رشته | یکی از user_id یا anonymous_id | حداکثر ۲۵۶ بایت. قاعدهٔ قالب ندارد، لازم نیست UUID باشد |
previous_id | رشته | وقتی type برابر alias است، بله | شناسهای که از آن ادغام میشود. برخلاف بقیهٔ شناسهها هیچ سقف طولی ندارد، فقط همان سقف بدنه |
timestamp | RFC 3339 | نه | پیشفرض، زمان دریافت روی سرور |
sent_at | RFC 3339 | نه | تصحیح اختلاف ساعت را روشن میکند. قبل از پرکردنش پایینتر را بخوانید |
properties | شیء | نه | حداکثر ۲۵۶ کلید، هر کلید ۱۲۸ بایت، هر مقدار رشتهای ۸۱۹۲ بایت |
traits | شیء | نه | حداکثر ۲۵۶ کلید، با همان حدود |
context | شیء | نه | ساختارش در رویدادها آمده |
timestamp و sent_at را کتابخانهٔ JSON زبان Go به زمان تبدیل میکند و آن فقط RFC 3339 را میپذیرد. ثانیهٔ یونیکس، میلیثانیهٔ یونیکس و تاریخ خالی 2026-08-07 هیچکدام تبدیل نمیشوند و کل درخواست با 400 malformed JSON رد میشود، نه فقط همان یک آیتم.
درآمد از داخل properties خوانده میشود، به این ترتیب: اولین مقدار ناصفر از revenue، total، value؛ اگر هیچکدام نبود، price ضرب در quantity که در نبودش ۱ فرض میشود. currency پیشفرض IRR است و بزرگحرف میشود، پس "irt" به شکل IRT ذخیره میشود. هیچ تبدیل نرخی انجام نمیشود.
هر وضعیتی که این آدرس برمیگرداند:
| موقعیت | کد | بدنه |
|---|---|---|
| پذیرفته شد، کامل یا بخشی | 200 | {"status":"ok","accepted":N,...} |
| کلیدی روی درخواست نبود | 401 | {"status":"error","message":"missing write key"} |
| کلید ناشناس، باطلشده، یا حساب معلق | 401 | {"status":"error","message":"invalid write key"} |
| جستوجوی کلید سمت ما شکست خورد | 503 با Retry-After: 5 | {"status":"error","message":"cannot verify the write key right now; retry"} |
| بدنهٔ بزرگتر از ۵ مگابایت | 413 | {"status":"error","message":"request body too large"} |
| بدنه JSON نیست | 400 | {"status":"error","message":"malformed JSON"} |
batch خالی است | 400 | {"status":"error","message":"batch_empty"} |
| بیشتر از ۵۰۰ آیتم | 400 | {"status":"error","message":"batch_too_large: 501 items, limit 500"} |
| حساب از سقف ماهانه گذشته | 402 | {"status":"error","message":"<جملهٔ فارسی>"} |
| هم باس و هم بافر دیسک شکست خوردند | 503، بدون Retry-After | {"status":"error","message":"temporarily unavailable, please retry"} |
کلید ناشناس، باطلشده و حساب معلق از روی عمد به یک 401 واحد تبدیل میشوند تا نشود از این آدرس برای فهمیدن اینکه چه کلیدهایی وجود دارند استفاده کرد.
پیام 402 همیشه فارسی است. کلکتور هیچ میانافزار زبانی ندارد، پس Accept-Language: en هیچ اثری روی آن نمیگذارد. روی کد وضعیت شرط بگذارید، نه روی متن.
API مدیریت: POST /v1/events
curl -sS https://api.segmentic.net/v1/events \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"type": "track",
"message_id": "order-8821-completed",
"event": "order_completed",
"user_id": "u_44120",
"timestamp": "2026-08-07T09:12:41Z",
"properties": { "order_id": "8821", "revenue": 4800000, "currency": "IRR" }
},
{
"type": "track",
"event": "order_completed",
"properties": { "order_id": "8822" }
}
]
}'
{
"accepted": 1,
"rejected": [ { "index": 1, "reason": "missing_identity" } ]
}
202 و نه 200، چون رویدادها در صف نشستهاند نه ذخیرهشده. چند ثانیه بعد قابل کوئری میشوند. اگر 200 میگفتیم، شما را تشویق میکرد بلافاصله بخوانیدشان و نتیجه بگیرید که گم شدهاند.
شکل هر آیتم همان پاکتی است که کلکتور میگیرد. چیزی که فرق دارد پوشش بیرونی است: نام آرایه events است، و فیلدهای context و sent_at در سطح بسته وجود ندارند. هرچه میخواستید آنجا بگذارید، باید روی تکتک آیتمها تکرار شود.
این مسیر فقط وقتی ثبت میشود که استقرار یک importer پیکربندیشده داشته باشد. وقتی ندارد، مسیر به هندلر پیشفرض میافتد و 404 unknown_endpoint میگیرد. GET /v1/capabilities وضعیتش را زیر features.ingest گزارش میکند.
دسترسی لازم profile.write است که نقشهای owner و admin و marketer دارند و analyst ندارد. متن رد، خود دسترسی موردنیاز را نام میبرد تا مجبور نشوید برای فهمیدنش تیکت پشتیبانی باز کنید:
{
"error": {
"code": "forbidden",
"message": "this key does not carry profile.write, see GET /v1/whoami for what it does carry",
"need": "profile.write"
}
}
هر وضعیتی که این آدرس برمیگرداند:
| موقعیت | کد | error.code |
|---|---|---|
| پذیرفته شد، کامل یا بخشی | 202 | ندارد |
| کلیدی نبود، یا کلید شناخته نشد | 401 | unauthenticated |
کلید نوشتن wk_ فرستاده شده | 401 | write_key_rejected |
| تاریخ انقضای کلید گذشته | 401 | key_expired |
کلید profile.write ندارد | 403 | forbidden |
events خالی است | 400 | batch_empty |
| بدنه JSON نیست | 400 | malformed_json |
| بیشتر از ۵۰۰ رویداد | 413 | batch_too_large، با details برابر {"limit":500,"sent":N} |
| همهٔ رویدادها رد شدند | 422 | all_events_rejected، با آرایهٔ آیتمها در details |
| حساب از سقف ماهانه گذشته | 402 | quota_cancelled، quota_trial_over، quota_event_cap یا quota_message_cap |
| بودجهٔ درخواست این کلید تمام شده | 429 با Retry-After: 60 | budget_exhausted |
| سرویس بودجه خطا داد | 503 | budget_unavailable |
| صف در دسترس نبود | 503 | ingest_unavailable |
روی این API فیلد error همیشه یک شیء نیست. ده مسیر از مسیرهای این API هندلر داشبورد را دوباره استفاده میکنند و بهجایش {"error":"یک رشته"} برمیگردانند: دو مسیر GET /v1/schema/*، POST /v1/audiences/count، فهرست و خواندن سگمنت، فهرست و خواندن کمپین، دو مسیر POST /v1/reports/*، و POST /v1/messages. خود POST /v1/events همیشه شکل شیء را میدهد، ولی کلاینتی که یک پارسر خطا را در کل API به اشتراک میگذارد باید قبل از خواندن error.code نوع error را چک کند.
بستهبندی و سقف آن
۵۰۰ آیتم در هر درخواست، روی هر دو در. حد سختی است: ۵۰۱ آیتم کامل رد میشود و هیچچیز از آن بسته ذخیره نمیشود. متن رد روی API مدیریت خود سقف را داخل details منتشر میکند تا کلاینتی که دارد اندازهٔ حلقهاش را تنظیم میکند، مجبور نباشد عدد را با آزمون و خطا پیدا کند.
سقف حجم بدنه جداست و اول با رویدادهای چاق پر میشود نه با تعداد زیاد. ۵ مگابایت روی کلکتور، ۸ مگابایت روی API مدیریت. ۵۰۰ رویداد که هر کدام ۲۵۶ ویژگی داشته باشند خیلی قبل از رسیدن به ۵۰۰ آیتم، از ۵ مگابایت رد میشوند.
فشردهسازی وجود ندارد. هیچکدام از این دو آدرس Content-Encoding را نمیخوانند، پس یک بدنهٔ gzip شده بهصورت بایتهایی میرسد که JSON نیستند و با 400 رد میشود. اگر رویدادهایتان بزرگاند، درخواست بیشتری بفرستید نه درخواست بزرگتر.
Content-Type هم اجبار نمیشود. بدنه هرچه اعلام کنید بهعنوان JSON خوانده و تجزیه میشود. با این حال application/json بفرستید تا پراکسی وسط راه تصمیم دیگری نگیرد.
زیر این سقف، اندازهٔ بسته فقط یک تصمیم توان عملیاتی است و بس. یک بستهٔ ۵۰۰ تایی روی کلکتور فقط یک رفتوبرگشت ردیس و یک رفتوبرگشت انتشار هزینه دارد، همانقدر که یک بستهٔ دوتایی، و دلیل اصلی ارزش داشتن بستهبندی همین است.
شکست بخشی، و خواندن خطای هر آیتم
یک آیتم خراب کل بسته را غرق نمیکند. هر دو آدرس همهٔ آیتمها را اعتبارسنجی میکنند، خوبها را نگه میدارند، و بدها را با شمارهٔ خانهشان در آرایهای که فرستادید نام میبرند.
کلکتور شمارشها را بههمراه آرایهٔ errors میدهد:
{
"status": "ok",
"accepted": 2,
"rejected": 1,
"errors": [ { "index": 1, "reason": "missing_identity" } ]
}
API مدیریت خود آرایه را زیر rejected میدهد:
{
"accepted": 2,
"rejected": [ { "index": 1, "reason": "missing_identity" } ]
}
یک کلمه، دو معنی. روی کلکتور rejected یک عدد است و جزئیات در errors است؛ روی API مدیریت rejected خود جزئیات است. پارسری که برای یکی نوشته شده، دیگری را «صفر خطا» میخواند.
index شمارهٔ خانه در آرایهای است که شما فرستادید، نه در زیرمجموعهٔ پذیرفتهشده. این عمدی است: رویدادهایی که رد میشوند ممکن است هنوز هیچ شناسهای نداشته باشند، که خودش نصف دلیل رد شدنشان است، پس شمارهٔ خانه تنها راه پیداکردن دوبارهٔ آنهاست.
reason با یک کد پایدار شروع میشود. فهرست کامل:
| کد | معنی |
|---|---|
unknown_type | type نبود یا جزو آن پنج مقدار نیست |
missing_identity | نه user_id بود و نه anonymous_id |
missing_event_name | type برابر track است و event خالی بود |
event_name_too_long | بعد از نرمالسازی از ۱۲۸ بایت گذشت |
event_name_invalid_chars | نام رویداد کاراکتر کنترلی دارد |
id_too_long | user_id یا anonymous_id یا message_id از ۲۵۶ بایت گذشت |
missing_previous_id | type برابر alias است و previous_id خالی بود |
یکی از اینها با مقدار خطاساز به دنبالش میآید، unknown_type، چون دانستن کد بدون دانستن مقدار شما را برمیگرداند سراغ لاگهای خودتان. آیتمی با "type": "trak" این متن را تولید میکند: unknown_type: "trak"، نه unknown_type خالی. شش کد دیگر همیشه تنها میآیند و هیچکدام نمیگویند کدام فیلد یا کدام مقدار خطا داشته.
reason را با پیشوند تطبیق بدهید، یا روی اولین ": " بشکنیدش. مقایسهٔ برابری با "unknown_type" روی unknown_type: "trak" عمل نمیکند، و این درست همان حالتی است که هشدارتان را برایش نوشته بودید.
وقتی همهٔ آیتمها رد میشوند، دو در دوباره از هم جدا میشوند. کلکتور 200 میدهد و accepted در بدنه نیست، چون این فیلد وقتی صفر باشد حذف میشود. API مدیریت 422 all_events_rejected میدهد و کل آرایه را در error.details میگذارد، با این استدلال که مدیریت خطای یک سرویس روی کد وضعیت شاخه میزند و بستهای که همهاش رد شده یک باگ سمت فراخوانکننده است که باید دیده شود.
هشدار با خطا فرق دارد: آیتم پذیرفته شد و چیزی در آن عوض شد. فقط کلکتور هشدار برمیگرداند، و حدود ۵۰ تا در هر پاسخ. POST /v1/events هشدارها را حساب میکند و دور میریزد، پس message_id جاافتاده آنجا بیصدا ساخته میشود و هرگز به شما گفته نمیشود.
| کد هشدار | چه اتفاقی افتاد |
|---|---|
generated_message_id | message_id فرستاده نشد؛ ارسال دوبارهٔ این رویداد قابل حذفشدن نیست |
timestamp_in_future | بیش از یک ساعت جلوتر از سرور؛ به زمان دریافت چسبانده شد |
timestamp_too_old | قدیمیتر از پنجرهٔ ورود؛ به لبهٔ پنجره چسبانده شد |
too_many_properties | بیش از ۲۵۶ ویژگی؛ اضافهها حذف شدند |
too_many_traits | بیش از ۲۵۶ ویژگی پرونده؛ اضافهها حذف شدند |
unserialisable_property | یک ویژگی قابل کدگذاری نبود و حذف شد. field نامش را میگوید |
invalid_phone | ویژگی پروندهٔ phone شمارهٔ موبایل ایرانی معتبری نبود؛ همانطور که فرستادید ذخیره شد و phone_operator برایش درنیامد |
invalid_national_id | ویژگی پروندهٔ national_id از بررسی رقم کنترلی رد نشد و اصلا ذخیره نشد |
message_id، و چرا سرور باید خودش بگذاردش
message_id تنها چیزی است که ارسال دوباره را امن میکند.
کلکتور حذف تکراری را روی جفت حساب شما و message_id انجام میدهد، با SET NX در ردیس و پنجرهٔ ۴۸ ساعته. تحویل اول منتشر میشود. هر تکرار داخل آن پنجره با 200 {"status":"ok","accepted":1} جواب میگیرد، درست مثل بار اول، و هیچ چیز تازهای به انبار داده نمیرسد. حساب هم نمیشود: یک کلاینت که دوباره میفرستد برای ما یک جستوجوی ردیس خرج دارد، نه یک سطر در فاکتور. تکراری داخل یک بسته هم گرفته میشود، پس بستهای که به اشتباه یک سفارش را دو بار آورده، یک بار ذخیره میشود.
پاسخ از روی عمد به شما نمیگوید که رویداد تکراری بود. کلاینتی که «تکراری» بشنود آن را خطا حساب میکند و باز میفرستد، و این درست همان حلقهای است که این پنجره برای بستنش وجود دارد.
اگر message_id نفرستید، یکی برایتان ساخته میشود و هشدار generated_message_id میگیرید. آن هشدار تزئینی نیست. یعنی این رویداد هیچ محافظتی در برابر ارسال دوباره ندارد، و عددی که تغذیه میکند هر بار که شبکهتان یک بعدازظهر بد داشته باشد، بالاتر میرود.
شناسه را از چیزی بسازید که دیتابیس خودتان یکتا بودنش را تضمین کرده، و قطعی بسازیدش تا ارسال دوباره همان رشته را حساب کند که بار اول:
| رویداد | یک message_id خوب |
|---|---|
| سفارش پرداخت شد | order-8821-completed |
| سفارش مرجوع شد | order-8821-refunded |
| وضعیت مرسوله عوض شد | shipment-4471-delivered |
| همگامسازی شبانهٔ پرونده | profile-44120-2026-08-07 |
هرگز از UUID تصادفیای که در لحظهٔ ارسال ساخته میشود استفاده نکنید. ارسال دوباره یکی دیگر میسازد و حذف تکراری چیزی برای کار کردن ندارد. از زمان هم به همین دلیل استفاده نکنید.
قاعدههای مقدار: فاصلههای ابتدا و انتها حذف میشود، حداکثر ۲۵۶ بایت، و محدودیت دیگری ندارد. دامنهاش حساب شماست، پس با شناسهٔ مشتری دیگری تداخل نمیکند.
POST /v1/events هیچ حذف تکراری انجام نمیدهد. message_id را میخواند، طولش را بررسی میکند، روی رویداد ذخیرهاش میکند، و هرگز چک نمیکند که پیشتر دیده باشدش. باز هم بفرستیدش تا بعدها بشود تکراریها را پیدا و پاک کرد، ولی انتظار نداشته باشید پلتفرم جلویشان را بگیرد.
زمان رویداد، عقببردن تاریخ، و قاعدهٔ اختلاف ساعت
timestamp را نگذارید و رویداد با زمان دریافت روی سرور مهر میخورد. برای رویدادی که بکاند شما همان لحظه منتشر میکند این درست است و یک فیلد کمتر برای اشتباه کردن.
timestamp را وقتی بگذارید که رویداد در لحظهای غیر از لحظهٔ ارسال اتفاق افتاده: یک جاب که هر ده دقیقه جدول outbox را خالی میکند، پرداختی که کالبک درگاه یک ساعت طول کشیده تا برسد، مهاجرت سفارشهای پارسال.
سه قاعده اعمال میشود، به همین ترتیب.
بیش از یک ساعت در آینده به زمان دریافت چسبانده میشود، با هشدار timestamp_in_future. زمانی جلوتر از الان فقط میتواند از یک ساعت خراب بیاید، و رد نکردنش یعنی ریختن رویداد داخل بازههای گزارشی که مشتری پیشتر خوانده و بسته است.
قدیمیتر از پنجرهٔ ورود درست به لبهٔ پنجره چسبانده میشود، با هشدار timestamp_too_old. هرگز رد نمیشود. در ورود زندهٔ داده، گوشیای که ساعتش خراب است نباید رویدادهایش را از دست بدهد. پیامدش برای مهاجرت داده سنگین است و ارزش صریح گفتن دارد: دو سال تاریخچه را از یک پنجرهٔ ۳۰ روزه بفرستید و همهٔ رویدادها روی یک لحظهٔ واحد مینشینند، پذیرفتهشده، با پاسخ موفق HTTP، و اولین نشانهاش ماهها بعد یک قیف است که هیچ معنایی ندارد.
پنجره یک ثابت سراسری نیست. روی کلکتور همان نگهداشت رویداد خود حساب است: حسابی که رویدادها را برای همیشه نگه میدارد ۳۶۵۰ روز میگیرد، حسابی که ۹۰ روز تنظیم کرده ۹۰ روز میگیرد، و حسابی که روی کف ۳۰ روزه نشسته باز هم همان پیشفرض کامل ۳۰ روزه را دارد. روی POST /v1/events این پنجره هرگز پر نمیشود، پس همیشه همان پیشفرض ثابت ۳۰ روزه اعمال میشود، هر چیزی که تنظیم نگهداشت شما بگوید.
اگر دارید تاریخچه مهاجرت میدهید، از POST /v1/batch کلکتور استفاده کنید و اول تنظیم نگهداشت رویداد حسابتان را ببینید. POST /v1/events هرچه قدیمیتر از ۳۰ روز باشد را میچسباند بدون اینکه چیزی را رد کند.
بعد نوبت sent_at است، و این همان فیلدی است که بیشترین احتمال را دارد یک مهاجرت داده را خراب کند.
sent_at برای گوشیای وجود دارد که ساعتش غلط است. SDK میگوید فکر میکند بسته را چه زمانی فرستاده؛ سرور آن را با زمان واقعی رسیدن مقایسه میکند؛ اختلاف را روی زمان رویداد اعمال میکند. دستگاهی که ساعتش دو ساعت عقب است و میگوید رویداد ساعت ۰۸:۰۰ رخ داده و ساعت ۱۰:۰۰ فرستاده، و بستهاش ساعت ۱۲:۰۰ میرسد، رویدادش با ۱۰:۰۰ ذخیره میشود. تصحیح فقط وقتی اجرا میشود که اختلاف از یک دقیقه بیشتر باشد، و فقط اگر زمان تصحیحشده هنوز داخل پنجره بیفتد. هیچ هشداری صادر نمیشود.
ساعت سرور شما درست است. پس یا sent_at را نگذارید، یا برابر همان لحظهای بگذاریدش که در عمل میفرستید. کاری که نباید بکنید کپیکردن timestamp داخل sent_at است، که نوشتنش طبیعی بهنظر میرسد و فاجعه است: برای رویدادی مربوط به سه روز پیش، فاصلهٔ «فرستاده» تا «رسیده» سه روز حساب میشود، همان سه روز به زمان رویداد اضافه میشود، و رویداد روی الان مینشیند. کل مهاجرت شما روی امروز فرو میریزد.
{
"batch": [
{
"type": "track",
"message_id": "order-7702-completed",
"event": "order_completed",
"user_id": "u_39900",
"timestamp": "2026-05-14T11:02:00Z",
"properties": { "order_id": "7702", "revenue": 1250000 }
}
]
}
کدام کد وضعیت را میشود دوباره فرستاد
| کد | دوباره بفرستم؟ | چرا |
|---|---|---|
200 / 202 | نه | کار کرد. قبل از رفتن، خطای هر آیتم را بخوانید |
400 | نه | بدنه خراب است و دفعهٔ بعد هم خراب خواهد بود |
401 | نه | کلید اشتباه است. تکرار یک سطر لاگ میسازد، نه یک راهحل |
402 | نه | حساب از سقف گذشته. تا کسی پرداخت نکند چیزی عوض نمیشود |
403 | نه | کلید profile.write ندارد. یک نفر باید کلید دیگری صادر کند |
404 | نه | روی API مدیریت یعنی مسیر ورود داده اینجا سرو نمیشود |
413 | نه | بسته بزرگ است. تکهاش کنید؛ همان بدنه هرگز رد نمیشود |
422 | نه | همهٔ رویدادها رد شدند. error.details را بخوانید |
429 | بله، بعد از Retry-After: 60 | فقط روی API مدیریت، بودجهٔ این کلید تمام شده |
503 | بله | مال ماست نه شما. پایینتر را ببینید |
| خطای شبکه بدون پاسخ | بله | ممکن است درخواست هرگز نرسیده باشد |
402 توضیح جدا لازم دارد، چون شبیه چیزی است که باید دوباره فرستاد و نیست. حساب از سقفی گذشته که خودش خواسته بوده. تکرار تا وقتی فاکتوری پرداخت نشود یا دورهٔ صورتحساب عوض نشود هیچ کاری نمیکند. در ضمن باگ سرویس شما هم نیست و نباید بهعنوان باگ لاگ شود.
روی هیچکدام از دو در، پاسخ 402 عدد ندارد. مقدار مصرفشده و سقف سمت ما حساب میشوند و همانجا دور ریخته میشوند، پس «چقدر رد کردم» را فقط از صفحهٔ صورتحساب میشود پرسید، نه از این پاسخ.
روی 402 کل بسته رد میشود، هرگز بخشی از آن. پذیرش جزئی شما را در وضعیتی میگذاشت که نمیدانستید کدام آیتمها را باید دوباره بفرستید.
برای هرچه قابل تکرار است، عقبنشینی نمایی بگذارید و سقف انتظار تعیین کنید. روی کلکتور هیچ محدودکنندهٔ نرخی وجود ندارد، یعنی طوفان تکرار شما پذیرفته میشود نه محدود، و یعنی تنها چیزی که شما را از حلقهٔ خودتان نجات میدهد، خود حلقهٔ شماست.
پاسخ ۵۰۳، و اینکه دور انداختنش یعنی از دست دادن داده
503 تنها کدی است که میگوید خرابی سمت ماست.
همیشه اینطور نبود، و دلیل اینکه حالا هست ارزش خواندن دارد، چون همین اشتباه در یک کلاینت هم راحت تکرار میشود. کلکتور پیشتر وقتی جستوجوی کلید خودش شکست میخورد 401 جواب میداد. یک SDK کد 401 را «این کلید هیچوقت کار نخواهد کرد» میخواند، متوقف میشود، و رویدادها را دور میریزد؛ کد 503 را «بعد دوباره امتحان کن» میخواند و نگهشان میدارد. پس یک قطعی دیتابیس بیصدا رویدادها را سمت مشتری نابود میکرد، در حالی که لاگ خود مشتری میگفت کلید APIاش نامعتبر است، که اشتباهترین چیزی است که آدم برود دنبال دیباگش. اندازهگیری شد، نه استدلال: با صفر کردن دیتابیس، هشت رویداد از هشت رویداد 401 گرفتند.
با 503 همانطور رفتار کنید. بسته را نگه دارید، صبر کنید، دوباره بفرستید. بهعنوان خطای اعتبارسنجی لاگش نکنید و دورش نیندازید.
کنارش هدر X-Segmentic-Trace را هم لاگ کنید: شانزده رقم هگز که هر دو در روی هر پاسخی میگذارند و در هیچ بدنهٔ خطایی تکرار نمیشود. برای پیگیری یک درخواست مشخص، تنها چیزی است که به کار ما میآید. اگر شما خودتان این هدر را روی درخواست بگذارید و مقدارش هگز با طول معقول باشد، همان برگردانده میشود.
روی کلکتور دو نوع 503 هست و فقط از روی پاسخ میشود از هم تشخیصشان داد:
Retry-After | پیام | یعنی چه | |
|---|---|---|---|
| جستوجوی کلید شکست خورد | 5 | cannot verify the write key right now; retry | درخواست هرگز به خط لوله نرسید. فرستادن دوبارهٔ همان بدنه درستترین کار است |
| انتشار شکست خورد | ندارد | temporarily unavailable, please retry | هم باس رویداد و هم بافر دیسک محلی شکست خوردند |
دومی کمیاب است، چون قطعی باس بهتنهایی آن را تولید نمیکند: کلکتور روی یک لاگ محلی مینویسد و وقتی باس برگشت، پخشش میکند. برای رسیدن به این حالت باید هر دو خراب شده باشند.
در 503 ناشی از شکست انتشار، مقدار message_id پیشتر در پنجرهٔ حذف تکراری ثبت شده است. ارسال دوباره با همان message_id تا ۴۸ ساعت بعد با 200 {"status":"ok","accepted":1} جواب میگیرد و ذخیره نمیشود: کلکتور نمیتواند این ارسال دوباره را از یک تکراری واقعی تشخیص بدهد. برای اینکه آن رویداد وارد شود، با یک message_id متفاوت بفرستیدش و بپذیرید که همین یک رویداد محافظتی در برابر تکرار ندارد. این تنها حالتی است که استفادهٔ دوباره از همان شناسه غلط است.
روی API مدیریت، 503 ingest_unavailable یعنی صف در دسترس نبود. بسته را دوباره بفرستید. چون آن آدرس حذف تکراری ندارد، 503 بعد از یک انتشار نیمهتمام هرچه رفته را دو بار میشمارد، که یک دلیل دیگر است برای ترجیح دادن کلکتور در هر چیزی که پول در آن هست.
503 budget_unavailable خرابی دیگری با همان کد است: سرویس بودجه خطا داد و API بهجای اینکه یک کلاینت بیشمارش را در حلقه رها کند، بسته عمل میکند. با عقبنشینی دوباره بفرستید. این یکی همهٔ مسیرهای api.segmentic.net را میگیرد، از جمله GET /v1/whoami را.
ترتیب
یک تضمین، و ارزش دارد بدانید تا کجا میرود.
هر رویداد با کلید پارتیشن user_id خودش روی باس گذاشته میشود، و اگر user_id نداشته باشد با anonymous_id. پس همهٔ رویدادهای یک نفر روی یک پارتیشن مینشینند، و مصرفکنندههایی که برای هر نفر حالت نگه میدارند (بهروزرسانی پرونده، حالت سناریو، دوختن نشست) آنها را به همان ترتیبی میبینند که باس پذیرفته، بدون هیچ هماهنگی بین پارتیشنها.
چه چیزی از این نتیجه میشود و چه چیزی نمیشود:
- داخل یک بسته، آیتمها به ترتیب آرایه منتشر میشوند، پس دو رویداد یک نفر در یک بسته ترتیبشان را حفظ میکنند.
- بین دو درخواست، ترتیب همان ترتیب پذیرفتهشدن درخواستهاست. دو درخواست همزمان از دو ورکر هیچ ترتیبی نسبت به هم ندارند.
- عوضشدن هویت یعنی عوضشدن پارتیشن. رویدادی که با
anonymous_idرفته و رویداد بعدی که باuser_idرفته روی دو پارتیشناند و هیچ ترتیبی نسبت به هم ندارند.aliasوidentifyبرای همین وجود دارند؛ هویت را ببینید. - ترتیب در قطعی باس حفظ نمیشود. رویدادهایی که در لاگ محلی بافر شدهاند روی یک تایمر و حدود هر پنج ثانیه پخش میشوند، پس رویدادی که در زمان قطعی پذیرفته شده میتواند بعد از رویدادهای بعد از خودش به باس برسد.
اگر ترتیب دو رویداد برای یک گزارش مهم است، به ترتیب ارسال تکیه نکنید. روی هر دو timestamp بگذارید.
سقف ماهانه و بودجهٔ درخواست
دو محدودیت متفاوت، روی دو در متفاوت، با دو کد وضعیت متفاوت.
سقف ماهانهٔ رویداد روی هر دو اعمال میشود. یک بار برای کل بسته و قبل از هر کار روی آیتمها بررسی میشود و با 402 رد میکند. باز عمل میکند: اگر خود بررسی خطا بدهد بسته پذیرفته میشود، چون از دست دادن دادهٔ یک مشتری وقتی دیتابیس یک لحظه پلک میزند، حادثهای بهمراتب بزرگتر از یک صورتحساب از کنترل خارجشده است.
بودجهٔ درخواست فقط روی api.segmentic.net است. وزنی است نه شمارشی، چون یک فراخوان یک ساختار را میخواند و فراخوان بعدی کل انبار داده را اسکن میکند. POST /v1/events پنج واحد خرج دارد. سهمیه ۶۰۰ واحد در دقیقه است و بهازای هر کلید API حساب میشود نه هر حساب، تا یک ایجنت از کنترل خارجشده نتواند بودجهای را که خط لولهٔ سفارش شما به آن وابسته است تمام کند. یعنی ۱۲۰ فراخوان ورود داده در دقیقه برای هر کلید، یا ۶۰۰۰۰ رویداد در دقیقه با بیشترین اندازهٔ بسته.
پنجره یک دقیقهٔ تقویمی ثابت است نه لغزان، و روی بودجه هیچ هدر X-RateLimit-* وجود ندارد. نمیتوانید بپرسید چقدر از آن برایتان مانده، و GET /v1/whoami هم گزارشش نمیکند. وقتی تمام شد 429 میگیرید با Retry-After: 60 که محافظهکارانه است: ممکن است دقیقه زودتر بچرخد.
کلکتور نه بودجهٔ درخواست دارد و نه هیچ نوع محدودکنندهٔ نرخی. تنها کنترل حجم روی آن، همان سقف ماهانه است.
User-Agent شما، و پرچم ربات
کلکتور آیپی و User-Agent را از خود اتصال برمیدارد، هرگز از بدنه، تا کلاینت نتواند موقعیت جغرافیایی یا دستگاه خودش را جعل کند. از مرورگر، این تنها راه فهمیدن این است که بازدیدکننده روی چه چیزی است. از بکاند شما یعنی رویداد با مهر دیتاسنتر شما و کلاینت HTTP شما ذخیره میشود.
بیشترش بیضرر است. مقدار browser_name میشود python-requests یا Go-http-client که نامرتب است و چیزی به شما نمیگوید که ندانید.
یک حالت بیضرر نیست. تجزیهکنندهٔ User-Agent وقتی رشتهٔ User-Agent یک نشانی وب داخلش داشته باشد، درخواست را ربات علامت میزند، و جدا از آن وقتی نام تجزیهشده رشتهٔ bot را در خود داشته باشد. همهٔ گزارشها، همهٔ کاشیهای داشبورد و کامپایلر سگمنت روی is_bot = 0 فیلتر میکنند. پس یک سرویس خوشرفتار که مؤدبانه خودش را همانطور معرفی میکند که از یک کلاینت HTTP انتظار میرود:
User-Agent: myshop-orders/1.0 (+https://myshop.ir)
رویدادهایی تولید میکند که ذخیره میشوند، حساب میشوند، در تایملاین خام کاربر دیده میشوند، و در هیچ گزارشی و هیچ سگمنتی دیده نمیشوند. هیچ خطایی نمیدهد. رویدادها فقط وقتی تیم بازاریابی نگاه میکند آنجا نیستند.
یک عبارت ساده بدون نشانی وب بفرستید، و رشتهٔ bot را در نام نیاورید:
User-Agent: myshop-orders/1.0
نفرستادن این هدر هم جواب میدهد: با نبودن User-Agent تجزیهکننده اجرا نمیشود و هیچکدام از فیلدهای دستگاه دست نمیخورند. POST /v1/events این هدر را هرگز نمیخواند، پس تمام این بخش به آن ربطی ندارد.
چیزهایی که وجود ندارند
نوشته شده چون فهمیدنشان با آزمایش، یک بعدازظهر خرج دارد.
- فشردهسازی نیست. هیچکدام از دو آدرس
Content-Encodingرا نمیخوانند، پس بدنهٔ gzip شده400 malformed JSONمیگیرد. - روی
POST /v1/eventsنه حذف تکراری هست و نه کلید idempotency. هدرIdempotency-Keyفقط روی یک آدرس در کل این API کار میکند،POST /v1/messages، و جای دیگری نه. POST /v1/eventsهشدار برنمیگرداند. هشدارها حساب و دور ریخته میشوند.- هیچ راهی برای خواندن دوبارهٔ یک رویداد، ویرایش یا حذف آن نیست.
GET /v1/eventsوجود ندارد. اصلاح با فرستادن یک رویداد جبرانی انجام میشود. - روی
503ناشی از شکست انتشار در کلکتور، هدرRetry-Afterنیست. فقط503جستوجوی کلید آن را دارد. - روی کلکتور هیچ محدودیت نرخی نیست: نه سقف در ثانیه، نه کنترل انفجار ترافیک، نه سقف همزمانی برای هر حساب.
- هیچ آدرس مهاجرت دادهای از
api.segmentic.netدر دسترس نیست. آدرسPOST /v1/import/eventsکه زمان خارج از پنجره را بهجای چسباندن رد میکند، فقط روی API داخلی داشبورد ثبت شده و عمومی مسیریابی نمیشود./v1/batchکلکتور و پنجرهٔ نگهداشت خود حساب، تنها راه دادهٔ قدیمیاند. - در نسخهٔ مستقرشده مکانیابی از روی آیپی نیست. مقادیر
countryوregionوcityفقط از چیزی پر میشوند که شما درcontext.locationمیفرستید. - روی
api.segmentic.netپاسخ preflight برای CORS نیست. مرورگر نمیتواند API مدیریت را صدا بزند؛ کلید نوشتن و کلکتور برای همیناند. - روی بودجهٔ درخواست هدرهای
X-RateLimit-LimitوX-RateLimit-Remainingنیست. - پاسخ
402کلکتور انگلیسی ندارد. همیشه فارسی است. - برای ترافیک بستهای، دیباگر زندهٔ رویداد کار نمیکند. دیباگر داشبورد ارسالهای تکرویدادی و رویدادهای وبهوک را ضبط میکند؛
/v1/batchچیزی به آن نمیدهد، پس یک سرویس سروری که بسته میفرستد آنجا چیزی نمیبیند.
یک برنامهٔ کامل به زبان Go
یک بسته را به کلکتور میفرستد، شکستهایی را که مال ماست دوباره میفرستد، و خطای هر آیتم را میخواند. فقط کتابخانهٔ استاندارد.
// Sends completed orders to Segmentic from a Go backend.
//
// export SEGMENTIC_WRITE_KEY=wk_seg_...
// go run main.go
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
const (
ingestURL = "https://in.segmentic.net/v1/batch"
maxBatchSize = 500
)
type envelope struct {
Type string `json:"type"`
MessageID string `json:"message_id"`
Event string `json:"event,omitempty"`
UserID string `json:"user_id"`
Timestamp *time.Time `json:"timestamp,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
Traits map[string]any `json:"traits,omitempty"`
}
// No sent_at field. It is for correcting a wrong device clock, and setting it
// from a server whose clock is right can only move timestamps that were
// already correct.
type batch struct {
Batch []envelope `json:"batch"`
Context map[string]any `json:"context,omitempty"`
}
type itemError struct {
Index int `json:"index"`
Reason string `json:"reason"`
}
type reply struct {
Status string `json:"status"`
Accepted int `json:"accepted"`
Rejected int `json:"rejected"`
Errors []itemError `json:"errors"`
Message string `json:"message"`
}
// errRetryable marks a failure that a second attempt can fix.
var errRetryable = errors.New("segmentic: temporarily unavailable")
// errBurnt marks the one 503 where the message_id has already been consumed:
// the publish failed after de-duplication recorded the ids, so an identical
// retry is answered 200 and stored nowhere.
var errBurnt = errors.New("segmentic: publish failed; message ids are spent")
func post(client *http.Client, key string, events []envelope) (reply, error) {
if len(events) > maxBatchSize {
return reply{}, fmt.Errorf("segmentic: %d events, limit %d", len(events), maxBatchSize)
}
body, err := json.Marshal(batch{
Batch: events,
Context: map[string]any{"locale": "fa-IR"},
})
if err != nil {
return reply{}, err
}
req, err := http.NewRequest(http.MethodPost, ingestURL, bytes.NewReader(body))
if err != nil {
return reply{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
// A plain token. A User-Agent containing a URL makes the parser flag the
// event as a bot, and every report filters bots out.
req.Header.Set("User-Agent", "myshop-orders/1.0")
res, err := client.Do(req)
if err != nil {
// The request may never have arrived, so the ids are still free.
return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
}
var out reply
if err := json.Unmarshal(raw, &out); err != nil {
return reply{}, fmt.Errorf("segmentic: unreadable reply, status %d: %s", res.StatusCode, raw)
}
switch res.StatusCode {
case http.StatusOK:
return out, nil
case http.StatusServiceUnavailable:
if res.Header.Get("Retry-After") != "" {
// The key lookup failed. Nothing reached the pipeline.
return out, fmt.Errorf("%w: %s", errRetryable, out.Message)
}
return out, fmt.Errorf("%w: %s", errBurnt, out.Message)
default:
// 400, 401, 402 and 413 all say the same thing on a second attempt.
return out, fmt.Errorf("segmentic: %d %s", res.StatusCode, out.Message)
}
}
func main() {
key := os.Getenv("SEGMENTIC_WRITE_KEY")
if key == "" {
log.Fatal("SEGMENTIC_WRITE_KEY is not set")
}
paidAt := time.Now().UTC().Add(-45 * time.Minute)
events := []envelope{
{
Type: "track",
// Derived from the order, so a retry computes the same string.
MessageID: "order-8821-completed",
Event: "order_completed",
UserID: "u_44120",
Timestamp: &paidAt,
Properties: map[string]any{
"order_id": "8821",
"revenue": 4800000,
"currency": "IRR",
"city": "تهران",
},
},
{
Type: "identify",
MessageID: "profile-44120-v7",
UserID: "u_44120",
Traits: map[string]any{
"phone": "09123456789",
"first_name": "سارا",
"city": "تهران",
},
},
}
client := &http.Client{Timeout: 15 * time.Second}
var out reply
var err error
for attempt := 1; attempt <= 5; attempt++ {
out, err = post(client, key, events)
if err == nil || !errors.Is(err, errRetryable) {
break
}
wait := time.Duration(1<<attempt) * time.Second
log.Printf("attempt %d failed (%v); waiting %s", attempt, err, wait)
time.Sleep(wait)
}
if err != nil {
log.Fatalf("segmentic: giving up: %v", err)
}
log.Printf("accepted %d, rejected %d", out.Accepted, out.Rejected)
for _, e := range out.Errors {
// Split on ": " because unknown_type carries the offending value.
code, _, _ := strings.Cut(e.Reason, ": ")
log.Printf("item %d (%s) rejected: %s", e.Index, events[e.Index].MessageID, code)
}
}
یک برنامهٔ کامل به زبان Python
جدول outbox را خالی میکند و در تکههای ۵۰۰ تایی به کلکتور میفرستد، با زمانهای عقببرده و بدون sent_at. به requests نیاز دارد.
#!/usr/bin/env python3
"""Send an outbox of paid orders to Segmentic.
pip install requests
export SEGMENTIC_WRITE_KEY=wk_seg_...
python send_orders.py
"""
import os
import sys
import time
from datetime import datetime, timedelta, timezone
import requests
INGEST_URL = "https://in.segmentic.net/v1/batch"
MAX_BATCH = 500
# Everything else means the payload or the credential is wrong, and a second
# attempt sends the same wrong thing.
RETRYABLE = {408, 500, 502, 503, 504}
def rfc3339(moment: datetime) -> str:
"""The only timestamp format the ingest endpoint decodes."""
return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def chunks(items, size):
for start in range(0, len(items), size):
yield items[start:start + size]
def post_batch(session: requests.Session, key: str, events: list) -> dict:
"""POST one batch. Returns the decoded reply or raises."""
if len(events) > MAX_BATCH:
raise ValueError(f"{len(events)} events, limit {MAX_BATCH}")
response = session.post(
INGEST_URL,
# No sent_at anywhere: these events are backdated, and sent_at would
# be read as clock skew and move every one of them to now.
json={"batch": events, "context": {"locale": "fa-IR"}},
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
# No URL in the token: a User-Agent containing one makes the
# event count as bot traffic, which every report filters out.
"User-Agent": "myshop-outbox/1.0",
},
timeout=20,
)
try:
body = response.json()
except ValueError:
body = {"message": response.text[:500]}
if response.status_code == 200:
return body
if response.status_code in RETRYABLE:
raise ConnectionError(f"{response.status_code}: {body.get('message')}")
raise RuntimeError(f"{response.status_code}: {body.get('message')}")
def send_with_retries(session, key, events, attempts=5):
for attempt in range(1, attempts + 1):
try:
return post_batch(session, key, events)
except (ConnectionError, requests.RequestException) as exc:
if attempt == attempts:
raise
wait = 2 ** attempt
print(f"attempt {attempt} failed ({exc}); waiting {wait}s", file=sys.stderr)
time.sleep(wait)
def main() -> int:
key = os.environ.get("SEGMENTIC_WRITE_KEY")
if not key:
print("SEGMENTIC_WRITE_KEY is not set", file=sys.stderr)
return 1
# Stand-in for the rows your own outbox query returns.
now = datetime.now(timezone.utc)
orders = [
{"id": 8821, "user": "u_44120", "rial": 4800000, "paid": now - timedelta(hours=3)},
{"id": 8822, "user": "u_39900", "rial": 1250000, "paid": now - timedelta(hours=2)},
{"id": 8823, "user": "", "rial": 990000, "paid": now - timedelta(hours=1)},
]
events = [
{
"type": "track",
# Deterministic, so a retry produces the same id and the second
# delivery is de-duplicated instead of counted again.
"message_id": f"order-{order['id']}-completed",
"event": "order_completed",
"user_id": order["user"],
"timestamp": rfc3339(order["paid"]),
"properties": {
"order_id": str(order["id"]),
"revenue": order["rial"],
"currency": "IRR",
},
}
for order in orders
]
session = requests.Session()
failures = 0
for part in chunks(events, MAX_BATCH):
reply = send_with_retries(session, key, part)
print(f"accepted {reply.get('accepted', 0)}, rejected {reply.get('rejected', 0)}")
for problem in reply.get("errors", []):
# Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
code = problem["reason"].split(": ", 1)[0]
bad = part[problem["index"]]
print(f" {bad['message_id']}: {code}", file=sys.stderr)
failures += 1
for note in reply.get("warnings", []):
print(f" warning {note['code']}: {note.get('note', '')}", file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
یک برنامهٔ کامل به زبان PHP
مسیر API مدیریت، برای بکاندی که همین حالا یک کلید sk_seg_ دارد. فقط به ext-curl و ext-json نیاز دارد.
<?php
/**
* Send events to Segmentic's management API from PHP.
*
* SEGMENTIC_API_KEY=sk_seg_... php send_events.php
*
* This endpoint does not de-duplicate. If this script can run twice over the
* same rows, mark them as sent in your own database inside a transaction.
*/
declare(strict_types=1);
const EVENTS_URL = 'https://api.segmentic.net/v1/events';
const MAX_BATCH = 500;
/**
* POST one batch. Returns the decoded 202 body.
*
* @throws RuntimeException with the HTTP status as its code.
*/
function segmenticSend(string $key, array $events): array
{
if (count($events) > MAX_BATCH) {
throw new RuntimeException(count($events) . ' events, limit ' . MAX_BATCH, 413);
}
$payload = json_encode(
['events' => $events],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
$curl = curl_init(EVENTS_URL);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
],
]);
$raw = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($raw === false) {
// No response at all. Treated as retryable: the request may never
// have arrived.
throw new RuntimeException('transport: ' . $error, 503);
}
$body = json_decode($raw, true);
if (!is_array($body)) {
throw new RuntimeException('unreadable reply: ' . substr($raw, 0, 300), $status);
}
if ($status === 202) {
return $body;
}
// error.code is the contract. error.message is prose and will be reworded.
// On ten other routes of this API, error is a plain string instead, so
// read its type before reaching into it.
$code = is_array($body['error'] ?? null)
? ($body['error']['code'] ?? 'unknown')
: (string) ($body['error'] ?? 'unknown');
throw new RuntimeException($code, $status);
}
$key = getenv('SEGMENTIC_API_KEY');
if ($key === false || $key === '') {
fwrite(STDERR, "SEGMENTIC_API_KEY is not set\n");
exit(1);
}
// No batch-level context or sent_at on this endpoint: those fields exist only
// on the collector, so anything shared has to be repeated per item.
$events = [
[
'type' => 'track',
'message_id' => 'order-8821-completed',
'event' => 'order_completed',
'user_id' => 'u_44120',
'timestamp' => gmdate('Y-m-d\TH:i:s\Z', time() - 1800),
'properties' => [
'order_id' => '8821',
'revenue' => 4800000,
'currency' => 'IRR',
'city' => 'تهران',
],
'context' => ['locale' => 'fa-IR'],
],
[
'type' => 'identify',
'message_id' => 'profile-44120-v7',
'user_id' => 'u_44120',
'traits' => ['phone' => '09123456789', 'city' => 'تهران'],
'context' => ['locale' => 'fa-IR'],
],
];
$attempt = 0;
while (true) {
$attempt++;
try {
$reply = segmenticSend($key, $events);
break;
} catch (RuntimeException $e) {
// 429 carries Retry-After: 60. 503 is ours. Everything else is fixed
// by changing the request, not by repeating it.
$retryable = in_array($e->getCode(), [429, 503], true);
if (!$retryable || $attempt >= 5) {
fwrite(STDERR, 'segmentic refused: ' . $e->getCode() . ' ' . $e->getMessage() . "\n");
exit(1);
}
$wait = $e->getCode() === 429 ? 60 : 2 ** $attempt;
fwrite(STDERR, "attempt {$attempt}: {$e->getMessage()}; waiting {$wait}s\n");
sleep($wait);
}
}
printf("accepted %d\n", $reply['accepted'] ?? 0);
foreach ($reply['rejected'] ?? [] as $item) {
// Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
$code = explode(': ', $item['reason'], 2)[0];
$bad = $events[$item['index']]['message_id'] ?? '(no message_id)';
fwrite(STDERR, "rejected {$bad}: {$code}\n");
}
توسعهٔ محلی
کلکتور روی http://localhost:8080 گوش میدهد و /v1/batch را همانجا سرو میکند، بدون هیچ تغییری در بدنه.
API مدیریت داستان دیگری دارد. روی آدرسی سرو میشود که در PUBLIC_API_ADDR نوشته شده، و مقدار پیشفرض آن خالی است، پس روی یک نصب تازه API عمومی سرو نمیشود. هیچ چیزی گوش نمیدهد، و اولین نشانهاش یک connection refused است که شبیه مشکل شبکه به نظر میرسد. مقدارش را بگذارید، ریاستارت کنید، و قبل از اینکه دنبال هر چیز دیگری بگردید با GET /v1/status چک کنید.
curl -sS http://localhost:8082/v1/status
{ "status": "ok", "service": "api", "version": "dev" }
GET /v1/status تنها مسیری روی API مدیریت است که کلید نمیخواهد و از قطعی ردیس جان سالم به در میبرد. بقیه، از جمله GET /v1/whoami، از بودجهٔ درخواست رد میشوند و بودجه بسته عمل میکند.
بعدش کجا
- رویدادها برای کل ساختار پاکت، از جمله تمام شیء
context. - فرهنگ رویدادها برای نامها و ویژگیهای استانداردی که قیفهای آماده را بدون پیکربندی به کار میاندازند.
- هویت برای
user_idوanonymous_idوalias. - خطاها برای همهٔ کدها روی هر دو سطح.
- محدودیتها برای هر عددی که پلتفرم شما را به آن پایبند میکند.
- API مدیریت برای سگمنت و کمپین و گزارش.