ساخت سگمنت و معنی دقیق هر شرط
زبان شرطها، همهٔ عملگرها، و تلهٔ اصلی: نام رویدادِ غلط ایراد نمیگیرد، فقط هیچکس را برنمیگرداند.
یک سگمنت، یک درخت JSON از شرطهاست. پنل هیچوقت SQL نمیفرستد؛ همین درخت را میفرستد و سرور آن را کامپایل میکند. برای شما یعنی هر کاری که پنل میکند از راه API هم شدنی است، و چند کار هست که فقط از راه API شدنی است: شرط روی ویژگی رویداد، تجمیع، و عضویت در سگمنت دیگر هیچکدام دکمهای در پنل ندارند.
این صفحه زبان شرطها را کامل میگوید: شکل دقیق JSON، هر نوع شرط، هر عملگر، پنجرههای زمانی، و فهرست چیزهایی که وجود ندارند. اگر ایجنت هوش مصنوعی هستید، بخش تله را قبل از نوشتن اولین فیلتر بخوانید.
شکل کلی یک تعریف
بیرونیترین لایه Definition است و دو فیلد دارد.
{
"version": 1,
"root": { "kind": "group", "op": "and", "children": [] }
}
| فیلد | نوع | لازم | توضیح |
|---|---|---|---|
version | عدد صحیح | نه | کامپایلر هیچوقت آن را نمیخواند. پنل همیشه 1 مینویسد. اگر ننویسید، 0 ذخیره میشود و هیچ اتفاقی نمیافتد. |
root | یک Node | بله | اگر نباشد، Node خالی با kind تهی میماند و کامپایلر با segment: unknown node kind: "" رد میکند. |
در همه فراخوانیهای HTTP این شیء یک لایه عمیقتر، داخل فیلدی به نام definition مینشیند:
{"definition": {"version": 1, "root": {"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}}}}
root لازم نیست گروه باشد. یک شرط تنها هم ریشه معتبری است.
هر گره یک ساختار واحد است با فیلد kind که تعیین میکند کدام فیلدهای دیگر خوانده میشوند. ترتیب فیلدها روی سیم اهمیت دارد، چون اثر انگشت سگمنت (که تأیید کمپین با آن «همان مخاطب» را از «مخاطبی که بعد از تأیید عوض شده» تشخیص میدهد) از sha256 بایتهای JSON ساخته میشود.
| فیلد JSON | نوع | کدام kind آن را میخواند |
|---|---|---|
kind | رشته | همه. یکی از group، trait، event، segment، engagement، churn |
op | رشته | فقط group |
not | بولین | فقط group، engagement، churn |
children | آرایه Node | فقط group |
trait | رشته | فقط trait |
compare_trait | رشته | فقط trait. ویژگی دوم بهجای value، در مقایسه دو ویژگی |
event | رشته | فقط event |
negate | بولین | فقط event |
count | شیء | فقط event |
aggregate | شیء | فقط event |
properties | آرایه شیء | فقط event |
window | شیء | فقط event |
segment_id | عدد صحیح | فقط segment |
in_segment | بولین | فقط segment |
band | رشته | فقط engagement و churn |
metric | رشته | فقط engagement |
operator | رشته | trait، engagement، churn |
value | شیء | trait، engagement، churn |
هر فیلدی که در ستون سوم نیامده باشد، روی آن نوع گره بیسروصدا نادیده گرفته میشود. این مهمترین منبع سردرگمی است: not: true روی یک گره trait یا event یا segment هیچ کاری نمیکند و هیچ خطایی هم نمیدهد. برای نفی رویداد negate را بگذارید و برای نفی عضویت in_segment: false.
کل درخت به یک شرط روی جدول پروندهها تبدیل میشود:
SELECT user_id FROM segmentic.profiles FINAL
WHERE tenant_id = {tenant:UInt32} AND (<شرط کامپایلشده>)
FINAL عمدی است و خواندن را گران میکند: بدون آن پروندهای که دو بار بهروزرسانی شده دو بار شمرده میشود، و اندازه غلط مخاطب اعتماد را همان لحظه از بین میبرد.
گروه: and، or و not
{"kind": "group", "op": "or", "not": false, "children": [ ]}
opفقط دو مقدار معنادار دارد:andوor. هر چیزی که دقیق برابرorنباشد، AND معنی میدهد."OR"با حروف بزرگ هم AND است. هیچ اعتبارسنجی روی این فیلد نیست و هیچ خطایی نمیگیرید.childrenنباید خالی باشد. آرایه خالی یعنی خطایsegment: group has no children.not: trueکل گروه را درNOT (...)میپیچد.- عمق تودرتویی: ریشه عمق صفر است و سقف عمق ۸ است، پس روی هم ۹ سطح. عمیقتر یعنی
segment: nesting too deep. - اندازه: سقف ۲۰۰ گره. بودجه هر گره را یک واحد و هر عضو آرایه
propertiesآن را هم یک واحد حساب میکند، چون شرط روی ویژگی رویداد هم یک شرط است. بیشتر یعنیsegment: too many conditions.
سازنده شرط در پنل کل درخت را ویرایش میکند: هر شرط عملگر خودش را دارد و با «و» یا «یا» به شرط بالای خودش وصل میشود، و انتخاب عملگری غیر از عملگر همان فهرست، آن دو شرط را در یک گروه میگذارد. پنل تا سه سطح گروه میسازد در حالی که کامپایلر تا نه سطح را میپذیرد؛ این سقف خوانایی است نه ایمنی، و تعریفی که از راه API عمیقتر ساخته شده باشد در پنل درست باز و درست ویرایش میشود، فقط عمیقتر نمیشود. گروهی که not دارد و شرط عضویت در سگمنت دیگر در پنل کنترلی ندارند: نشان داده میشوند و دستنخورده میمانند.
شرط روی ویژگی پرونده
{"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}}
نام ویژگی trim میشود؛ تهی یا بلندتر از ۱۲۸ بایت یعنی segment: invalid identifier.
نام به همین ترتیب در چهار دسته گشته میشود و اولین جایی که پیدا شد برنده است: شش پرچم دسترسپذیری، هفت ویژگی عددی محاسبهشده، شانزده ستون رشتهای، و بعد birthday. نامی که در هیچکدام نباشد ویژگی سفارشی است.
ویژگیهایی که ستون واقعیاند
شش پرچم دسترسپذیری. has_push، has_email، has_phone، push_opt_in، email_opt_in، sms_opt_in.
اینها به col = 1 یا col = 0 تبدیل میشوند. منطق ساده و غافلگیرکننده است: مقدار پیشفرض «بله» است، اگر value.bool بفرستید همان میشود، و اگر عملگر neq باشد وارونه میشود. هر عملگر دیگری، از gt تا contains تا is_set، دقیق مثل تساوی رفتار میکند. پس has_push با عملگر contains هم قابل کامپایل است و همان کاری را میکند که eq میکرد.
هفت ویژگی عددی محاسبهشده.
| نام ویژگی | چه چیزی را میشمارد |
|---|---|
total_events | تعداد کل رویدادهای این پرونده |
total_revenue | مجموع مبلغ خریدها |
order_count | تعداد سفارش |
days_since_last_seen | dateDiff('day', last_seen, now()) |
days_since_last_order | dateDiff('day', last_order_at, now()) |
days_until_birthday | روز تا تولد بعدی: امروز صفر است، سه روز دیگر سه |
days_until_signup_anniversary | همان حساب روی first_seen |
days_until_birthday یک سالگرد است، نه یک تاریخ. تاریخ تولد ذخیرهشده یک تاریخ در گذشته است و مقایسه آن با یک پنجره زمانی بعد از سال اول هیچکس را برنمیگرداند. ۲۹ فوریه در سال غیرکبیسه روی ۱ مارس میافتد. برای کسی که تاریخ تولد ندارد مقدار NULL است، پس هر مقایسهای روی او نادرست میشود و ساده از مخاطب بیرون میماند.
days_until_signup_anniversary قبل از مقایسه ماه و روز، first_seen را که یک DateTime است به روز کوتاه میکند. بدون این کار کسی که ساعت ۲۳:۳۰ ثبتنام کرده با کسی که فردا ۰۰:۳۰ ثبتنام کرده یک روز اختلاف پیدا میکرد.
هر هفتتا عددیاند، پس روی آنها is_set یعنی expr != 0 و is_not_set یعنی expr = 0. یک ویژگی عددی که برابر صفر است، «ثبت نشده» خوانده میشود.
شانزده ستون رشتهای. user_id، email، phone، first_name، last_name، gender، city، region، country، language، timezone، device_type، os_name، app_version، push_provider، national_id.
national_id تا مدتی در این فهرست نبود در حالی که تمام مدت ذخیره میشد، پس هر سگمنتی روی آن، برای هر مشتری، هیچکس را برنمیگرداند و هیچ خطایی هم هیچجا نبود. همین حادثه دلیل وجود این فهرست است.
ویژگیهای خودتان
هر نامی که در آن چهار دسته نباشد، یک ویژگی سفارشی است و در نگاشتهای ClickHouse دنبالش میگردیم. نام ناشناس خطا نیست، چون مشتریها مدام ویژگی خودشان را تعریف میکنند و رد کردن نامهای ناشناس این قابلیت را بیمصرف میکرد. شاخهای که انتخاب میشود به شکل مقایسه بستگی دارد.
| شکل مقایسه | SQL |
|---|---|
is_set یا is_not_set | has(mapKeys(traits), {p0:String}) و نفی آن |
مقدار بولین با eq یا neq | lower(traits[{p0:String}]) = {p1:String} که {p1} رشته true یا false است |
عملگر عددی، یا هر عملگری با مقدار عددی بجز in و not_in | (mapContains(traits_num, {p0:String}) AND traits_num[{p0:String}] < {p1:Float64}) |
| هر چیز دیگر | traits[{p0:String}] با قواعد رشتهای بخش عملگرها |
سه نکته که هر کدام یک باگ واقعی روی داده مشتری بودند.
حضور با کلید سنجیده میشود، نه با مقدار. ویژگی ثبتنشده و ویژگی ثبتشده با مقدار تهی دو چیز متفاوتاند، و is_set روی ویژگی سفارشی تنها جایی است که این دو از هم جدا میشوند.
بولین با متن مقایسه میشود. ویژگیای که به شکل JSON true فرستاده شده در traits رشته "true" و در traits_num عدد 1 ذخیره میشود. نگاشت متنی انتخاب شده چون نیمهای است که همه پروندهها دارند. جهت نفی هم عمدی است: neq true یعنی «میدانیم false است»، نه «نمیدانیم true است»، پس کسی که این ویژگی را هرگز نفرستاده در هیچکدام از دو مخاطب نیست.
عدد با mapContains محافظت میشود. نگاشت ClickHouse برای کلید موجودنبود صفر برمیگرداند، پس بدون این محافظ «موجودی کمتر از ۱۰» هر پروندهای را که هیچ موجودی ندارد برمیگرداند: این باگ اول بار با ۱۱۴۹۴۳ کاربر روی حسابی با حدود ۱۱۵۰۰۰ کاربر گزارش شد، عددی که مثل یک جواب واقعی به نظر میرسد. جهت شکست عمدی انتخاب شده است: شرط عددی روی ویژگیای که هیچکس ندارد حالا هیچکس را انتخاب میکند نه همه را. مخاطب بیسروصدا خالی یعنی کمپینی که نمیرود و کسی متوجه میشود؛ مخاطب بیسروصدا همه یعنی کمپینی که رفته و برنمیگردد.
birthday فقط به دو سؤال جواب میدهد
birthday یک ستون Nullable(Date) است و تنها is_set و is_not_set را میپذیرد:
{"kind": "trait", "trait": "birthday", "operator": "is_set"}
هر عملگر دیگری در زمان کامپایل رد میشود با این متن:
segment: unsupported operator: birthday only answers is_set and is_not_set; for an anniversary use days_until_birthday
دلیلش این است که مقایسه غیرعددی is_set ستون را با رشته تهی میسنجد و ClickHouse این را روی یک Date با Code: 38. Cannot parse date رد میکند. برای سؤال سالگرد از days_until_birthday استفاده کنید که یک عدد است.
مقایسه دو ویژگی با هم
compare_trait بهجای مقدار ثابت، ویژگی دومی را میگذارد، تا یک شرط بتواند دو عددی را بپرسد که خود پرونده هر دو را دارد:
{"kind": "trait", "trait": "gc_referrals_total", "operator": "gt", "compare_trait": "gc_referrals_active"}
این یعنی «کسی را دعوت کرده که هنوز فعال نشده»، و هیچ عدد ثابتی این را نمیگوید: خط برای کسی که دو نفر را دعوت کرده روی دو است و برای کسی که چهل نفر را دعوت کرده روی چهل. روی همان حسابی که این نیاز از آن آمد، این مخاطب ۸۸۸ نفر است. نزدیکترین چیزی که با یک آستانه ثابت به دست میآید ۴۰ نفر است.
وقتی compare_trait ست شده باشد، value خوانده نمیشود.
شش عملگر. eq، neq، gt، gte، lt، lte. هر چیز دیگری در زمان کامپایل رد میشود، چون between دو کران میخواهد و in یک فهرست، و یک ویژگی دوم هیچکدام نیست، و is_set فقط درباره یک طرف میپرسد:
segment: unsupported operator: comparing two traits takes eq, neq, gt, gte, lt or lte, got "between"
هر دو طرف باید عدد باشند. پرچمهای دسترسی UInt8 هستند و عدد حساب میشوند. ستون متنی در هر طرف با نام خودش رد میشود، و birthday هم همان رد قبلی خودش را نگه میدارد:
segment: unsupported operator: city holds text, and comparing two traits compares numbers
دلیلش این است که وقتی هیچ طرفی مقدار ثابت نیست، چیزی نمانده که شکل مقایسه از آن خوانده شود. مسیر تکویژگی بین traits_num و traits را از روی عملگر و مقداری که گرفته انتخاب میکند، و اینجا مقداری در کار نیست. عدد بودن مبهم نمیماند، چون یک ویژگی عددی موقع ورود در هر دو نقشه نوشته میشود، پس mapContains(traits_num, key) جواب مطمئن «این ویژگی عدد است» را میدهد. برای جهت دیگر چنین آزمونی وجود ندارد، پس یک جفت متنی مجبور بود نقشه را حدس بزند، و حدسی که نقشه خالی را بخواند هیچکس را انتخاب میکند و در عین حال شبیه جواب به نظر میرسد.
پروندهای که یکی از دو طرف را ندارد در مخاطب نیست. هر ویژگی سفارشی نگهبان حضور خودش را همراه دارد، و days_until_birthday و شکل days_since_ وقتی چیزی برای شمردن نباشد از قبل NULL میدهند. این همان جهت شکستی است که مسیر عددی ویژگیها انتخاب کرده بود: مخاطبی که بیصدا خالی است کمپینی است که نمیرود و دیده میشود، و مخاطبی که بیصدا همه است کمپینی است که رفته.
| دو طرف | SQL |
|---|---|
| دو ویژگی سفارشی | (mapContains(traits_num, {p0:String}) AND mapContains(traits_num, {p1:String}) AND traits_num[{p0:String}] > traits_num[{p1:String}]) |
| دو ستون واقعی | (order_count > total_events) |
| یکی از هر کدام | (mapContains(traits_num, {p0:String}) AND order_count < traits_num[{p0:String}]) |
پنل این یکی را نمیسازد. سطر شرط یک کنترل مقدار دارد و این شرط به انتخابگر ویژگی دوم احتیاج دارد، پس سازنده چنین شرطی را فقطخواندنی نشان میدهد و جعبه مقداری که کامپایلر هیچوقت نمیخواندش را نمیکشد.
شرط روی رویداد
{
"kind": "event",
"event": "order_completed",
"negate": false,
"window": {"kind": "last", "amount": 30, "unit": "day"},
"properties": [],
"count": {"operator": "gte", "value": 3}
}
نام رویداد trim میشود؛ تهی یا بلندتر از ۱۲۸ بایت یعنی segment: invalid identifier.
هر شرط رویداد به یک زیرکوئری روی segmentic.events تبدیل میشود که همیشه این سه شرط پایه را دارد:
tenant_id = {tenant:UInt32}
AND name = {p0:String}
AND is_bot = 0
فیلتر ربات همیشگی است و هیچ راهی برای خاموش کردنش نیست. کسی نمیخواهد به یک خزنده پوش بفرستد.
بعد پنجره زمانی، بعد شرطهای properties، و در آخر یک HAVING که یا از aggregate میآید یا از count. GROUP BY user_id فقط وقتی اضافه میشود که HAVING وجود داشته باشد.
بیرونیترین لایه:
negateغایب یاfalse:user_id IN (زیرکوئری)negate: true:user_id NOT IN (زیرکوئری)
NOT IN عمدی است: «انجام نداده» باید کسانی را که هیچ رویدادی ندارند هم شامل شود، و چون کوئری بیرونی از جدول پروندهها میآید، NOT IN این را رایگان میدهد.
ترکیب negate: true با count را با دقت بخوانید. هر دو اعمال میشوند، پس نتیجه user_id NOT IN (... HAVING count() >= 3) است، یعنی «حداقل سه بار انجام نداده»، که کسی را که دو بار انجام داده در مخاطب نگه میدارد. کامپایلر هشدار نمیدهد. جمله توصیفی هم کمکی نمیکند: برای رویداد نفیشده، شمارش در جمله نوشته نمیشود و فقط «انجام ندادهاند» را میخوانید.
شرط روی ویژگی رویداد
{"property": "category", "operator": "eq", "value": {"type": "string", "str": "موبایل"}}
سه فیلد و بس: property، operator، value. نام ویژگی لازم است و سقف ۱۲۸ بایت دارد.
ترتیب تصمیمگیری:
revenueمستقیم روی ستون ارتقایافتهrevenueمینشیند و عددی حساب میشود.- عملگر عددی، یا هر عملگری با مقدار عددی بجز
inوnot_in، بهprops_num[{key:String}]میرود. - مقدار بولین با
eqیاneqرویprops_str[key]با رشتهtrueیاfalseمقایسه میشود. is_setوis_not_setبهhas(mapKeys(props_str), {key:String})تبدیل میشوند.- باقی همه روی
props_str[{key:String}]با قواعد رشتهای.
دو چیز که باید بدانید. اول اینکه اعضای properties همیشه با AND به هم وصل میشوند؛ هیچ راهی برای OR بین دو شرط ویژگی یک رویداد وجود ندارد. دوم اینکه برخلاف مسیر ویژگی پرونده، اینجا محافظ mapContains نیست. props_num برای کلیدی که رویداد نداشته صفر برمیگرداند، پس شرط "revenue_share" lt 10 رویدادهایی را هم میگیرد که این ویژگی را هرگز نداشتهاند.
تعداد دفعات
{"count": {"operator": "gte", "value": 3}}
| فیلد | نوع | توضیح |
|---|---|---|
operator | رشته | یکی از eq، neq، gt، gte، lt، lte، between |
value | عدد | حد یا کران پایین |
value2 | عدد | فقط برای between، کران بالا |
به HAVING count() <op> {value:Float64} تبدیل میشود، و برای between به HAVING count() BETWEEN {a:Float64} AND {b:Float64}. مقدار به شکل Float64 بایند میشود، پس تعداد اعشاری بدون هیچ اعتراضی پذیرفته میشود و کاری که انتظار دارید نمیکند.
count() سطرهای رویداد را میشمارد. شمارش مقدارهای یکتا وجود ندارد.
پنل فقط gte، gt، lte، lt و eq را نشان میدهد؛ between فقط از API قابل نوشتن است.
تجمیع روی یک ویژگی عددی
{"aggregate": {"function": "sum", "property": "revenue", "operator": "gte", "value": 2000000}}
| فیلد | نوع | توضیح |
|---|---|---|
function | رشته | فقط sum، avg، min، max. بزرگ و کوچک حروف مهم نیست. |
property | رشته | revenue یا هر کلید عددی رویداد |
operator | رشته | همان شش عملگر عددی، بهعلاوه between |
value | عدد | حد |
value2 | عدد | فقط برای between |
نامی خارج از آن چهار تابع یعنی segment: invalid identifier. تابع count در اینجا وجود ندارد؛ برای شمردن از count بخش قبل استفاده کنید. first و last هم وجود ندارند، پس هیچ راهی برای شرط گذاشتن روی مقدار ویژگی در آخرین رخداد یک رویداد نیست.
اینجا هم مثل properties محافظ mapContains نیست، پس sum روی ویژگیای که بیشتر رویدادها ندارند بیسروصدا صفر جمع میزند.
aggregate بیسروصدا بر count مقدم است. اگر هر دو را بفرستید، count نادیده گرفته میشود.
پنجرهای که تجمیع روی آن اجرا میشود، همان window خود گره رویداد است. فیلد جداگانهای برای دوره تجمیع وجود ندارد.
عضویت در سگمنت دیگر
{"kind": "segment", "segment_id": 1234, "in_segment": true}
به این تبدیل میشود:
user_id IN (SELECT user_id FROM segmentic.segment_members
WHERE tenant_id = {tenant:UInt32} AND segment_id = {p0:UInt64})
segment_id باید باشد و غیرصفر باشد، وگرنه segment: invalid identifier: segment_id must be set.
دو تله اینجاست و هر دو بیسروصدا هستند.
in_segment پیشفرض false است و false یعنی NOT IN. ننوشتن این فیلد یعنی «عضو نیست»، نه «عضو هست».
این شرط فقط جدول فهرستهای ثابت را میخواند. segment_members جایی است که عضویت سگمنتهای static نوشته میشود. یک سگمنت dynamic هیچ سطری آنجا ندارد، پس اشاره به آن یک مجموعه تهی میدهد. این خطا نیست، فقط صفر است.
تعامل
امتیازهای تعامل هر شب روی کل تاریخچه پیامها محاسبه میشوند و در جدول خودشان زندگی میکنند، چون این یک اسکن است که هیچ نوشتن روی پرونده نمیتواند آن را حمل کند. به همین دلیل یک kind جداگانه است و نه یک ویژگی عددی.
دو شکل دارد که به همین ترتیب بررسی میشوند.
گروه. band را بگذارید. مقدارهای مجاز: engaged، passive، dormant، lost، new.
{"kind": "engagement", "band": "dormant"}
سنجه. metric را بگذارید. مقدارهای مجاز: score، ignored_streak، open_rate، click_rate، days_since_engaged.
{"kind": "engagement", "metric": "ignored_streak", "operator": "gte", "value": {"type": "number", "num": 10}}
عملگر باید عددی باشد: فقط gt، gte، lt، lte، between. توجه کنید که eq و neq در این تعریف عددی نیستند و رد میشوند، با متن segment: unsupported operator: engagement needs a numeric operator, got "eq". یک نرخ یا یک زنجیره بیپاسخ، «شامل» معناداری ندارد.
اگر نه band بدهید و نه metric: segment: invalid identifier: engagement needs a band or a metric.
نام گروه و نام سنجه هر دو اعتبارسنجی میشوند، برخلاف نام رویداد. دلیلش صریح در کد نوشته شده: غلط املایی بیسروصدا هیچکس را برنمیگرداند، و سگمنتی که هیچکس را برنمیگرداند دقیق شبیه سگمنتی است که مخاطبش ساکت شده، و همین دومی چیزی است که این قابلیت برای تشخیصش ساخته شده.
not: true روی این گره کار میکند و به NOT IN تبدیل میشود، که درست هم هست: کسی که کار شبانه هرگز امتیازش نداده، بهطور قطع «فعال اثباتشده» نیست.
زیرکوئری با FINAL اجرا میشود، چون جدول یک ReplacingMergeTree است که کار شبانه بازنویسیاش میکند و بدون FINAL کسی که دو شب پشتسرهم امتیاز گرفته روی سطر قدیمی هم میافتد.
ریسک ریزش
{"kind": "churn", "band": "high"}
گروه. band یکی از high، medium، low، unknown.
آستانه. operator را بگذارید و عددی باشد. مقایسه روی ستون probability انجام میشود که درصد صحیح است، نه کسر. یعنی «ریسک ریزش بیشتر از ۷۰» را با 70 مینویسید نه با 0.7.
{"kind": "churn", "operator": "gt", "value": {"type": "number", "num": 70}}
اگر هیچکدام: segment: invalid identifier: churn needs a band or a threshold. اگر عملگر عددی نباشد: segment: unsupported operator: churn risk needs a numeric operator, got "eq".
دقت کنید که تقارن با تعامل برقرار نیست: churn شکل آستانه را وقتی انتخاب میکند که operator تهی نباشد، در حالی که engagement شکل سنجه را وقتی انتخاب میکند که metric تهی نباشد. گره churn که هم band و هم operator دارد، band را برمیدارد.
کسی که پیشبینی ندارد به عمد نه در هیچ گروهی است و نه بالای هیچ آستانهای، پس از هر دو شکل بیرون میافتد. کسی که مدل هرگز ندیده «کمریسک» نیست؛ نامشخص است، و کمپین بازگردانیای که این دو را یکی بگیرد بودجهاش را خرج کسانی میکند که هیچکس نگاهشان نکرده.
not: true کار میکند و NOT IN میدهد. زیرکوئری هم FINAL دارد.
عملگرها
این پانزده رشته همه عملگرهای موجودند. رشته را دقیق همینطور بنویسید.
| عملگر | نوع مقدار | معنی | SQL تولیدشده |
|---|---|---|---|
eq | رشته، عدد، بولین | برابر است | lower(expr) = {p:String} یا expr = {p:Float64} |
neq | رشته، عدد، بولین | برابر نیست | lower(expr) != {p:String} یا expr != {p:Float64} |
contains | رشته | زیررشته، بدون حساسیت به بزرگی حروف | positionCaseInsensitiveUTF8(expr, {p:String}) > 0 |
not_contains | رشته | زیررشته نیست | positionCaseInsensitiveUTF8(expr, {p:String}) = 0 |
starts_with | رشته | با این شروع میشود | startsWith(lower(expr), {p:String}) |
ends_with | رشته | به این ختم میشود | endsWith(lower(expr), {p:String}) |
gt | عدد | بیشتر از | expr > {p:Float64} |
gte | عدد | حداقل | expr >= {p:Float64} |
lt | عدد | کمتر از | expr < {p:Float64} |
lte | عدد | حداکثر | expr <= {p:Float64} |
between | عدد، هم num و هم num2 | بازه بسته از دو طرف | expr BETWEEN {a:Float64} AND {b:Float64} |
in | فهرست رشته | یکی از اینها | has({p:Array(String)}, lower(expr)) |
not_in | فهرست رشته | هیچکدام از اینها | NOT has({p:Array(String)}, lower(expr)) |
is_set | بدون مقدار | ثبت شده است | بسته به نوع ستون، پایین را ببینید |
is_not_set | بدون مقدار | ثبت نشده است | بسته به نوع ستون، پایین را ببینید |
هر عملگری بجز is_set و is_not_set به value نیاز دارد. نبودش یعنی segment: operator requires a value.
is_set سه معنی متفاوت دارد و این تفاوت واقعی است، نه ظرافت:
| کجا | is_set | is_not_set |
|---|---|---|
| ستون عددی | expr != 0 | expr = 0 |
| ستون رشتهای | expr != '' | expr = '' |
| ویژگی سفارشی | has(mapKeys(traits), key) | نفی همان |
birthday | birthday IS NOT NULL | birthday IS NULL |
هر مقایسه رشتهای، هر دو طرف را تا میکند تا فیلتری که با «ی» فارسی نوشته شده، پروندهای را که با یای عربی (U+064A) ذخیره شده هم بگیرد. این شایعترین دلیلی است که مخاطب دستساز کوتاه برمیگردد. تهراني و تهرانی هر دو به یک مقدار بایند میشوند. همین تا کردن با کامپایلر تحلیلها مشترک است، چون داشبوردی که روی «تهران» فیلتر شده باید دقیق همان آدمهایی را بشمارد که این سگمنت میشمارد.
کل فهرست in به شکل یک پارامتر Array(String) میرود، پس فهرست هزار شهری هم یک جاینگهدار است.
مقدارها
{"type": "number", "num": 1000, "num2": 5000}
type | کدام فیلد بار را میبرد |
|---|---|
string | str |
number | num و برای کران بالای between هم num2 |
bool | bool |
list | list، آرایهای از رشته |
date | date و date2 |
type هیچوقت با عملگر تطبیق داده نمیشود. مقدار {"type": "string", "str": "۵"} با عملگر gt باعث میشود کامپایلر num را بخواند که صفر است، و شرط expr > 0 میشود. خطایی نمیگیرید.
type: "date" روی سیم پذیرفته میشود و کامپایلر هرگز آن را نمیخواند. تابع مقایسه فقط num، num2، str و list را میخواند. یک مقدار تاریخ با عملگر رشتهای در عمل با رشته تهی مقایسه میشود. مقایسه تاریخ روی ویژگی پرونده پیادهسازی نشده است. برای سؤال سالگرد از days_until_birthday و days_until_signup_anniversary استفاده کنید.
فهرست حداکثر ۱۰۰۰ عضو دارد؛ بیشتر یعنی segment: list has too many values. فهرست تهی با in یا not_in یعنی segment: operator requires a value.
پنجره زمانی
{"kind": "last", "amount": 30, "unit": "day"}
window فقط روی گره event خوانده میشود. روی trait، segment، engagement و churn بیسروصدا نادیده گرفته میشود.
kind | فیلدهای لازم | شرط تولیدشده |
|---|---|---|
all_time یا رشته تهی | هیچ | هیچ شرطی روی event_time گذاشته نمیشود |
last | amount، unit | event_time >= now() - INTERVAL {p:UInt32} <UNIT> |
between | from، to | event_time BETWEEN {p:DateTime64(3)} AND {p:DateTime64(3)} |
after | from | event_time >= {p:DateTime64(3)} |
before | to | event_time < {p:DateTime64(3)} |
نبودن کل شیء window همان all_time است. هر kind دیگری یعنی segment: invalid time window: kind "...".
پنجره نسبی. unit یکی از minute، hour، day، week، month است و بزرگی حروف مهم نیست. واحد تنها بخشی از کوئری است که نمیتواند پارامتر بایندشده باشد، و فهرست مجاز دقیق به همین دلیل وجود دارد. amount باید بین ۱ و ۱۰۰۰۰ باشد.
INTERVAL n MONTH در ClickHouse یک ماه تقویمی است. ولی تابعی که زمینبازی زمانی یک تعریف را برای زمانبند حساب میکند، ماه را ۳۰ روز میگیرد. یعنی برای پنجرههای ماهانه، پیشبررسی زمانبند و کوئری واقعی کمی با هم اختلاف دارند.
پنجره مطلق. from و to مهرزمان RFC 3339 هستند.
{"kind": "between", "from": "2026-03-21T00:00:00Z", "to": "2026-06-21T00:00:00Z"}
between هر دو کران را میخواهد و to نباید قبل از from باشد، وگرنه segment: invalid time window: between needs from and to یا segment: invalid time window: to is before from. after فقط from میخواهد و before فقط to. دقت کنید که after شامل خود لحظه است (>=) و before نیست (<).
همهچیز UTC است. فیلد منطقه زمانی روی پنجره وجود ندارد، منطقه زمانی مشتری روی پنجره اعمال نمیشود، و تاریخ جلالی روی سیم فرستاده نمیشود. این تصمیم صریح است: پنل تاریخ را جلالی نشان میدهد و همیشه لحظه UTC میفرستد.
جلالی فقط در جمله توصیفی ظاهر میشود. پنجره {"kind": "after", "from": "2026-03-21T00:00:00Z"} در فارسی اینطور خوانده میشود:
پس از ۱ فروردین ۱۴۰۵
و همان لحظه در انگلیسی 21 March 2026 است.
پنل between را مینویسد، before و after را نه. کنترل پنجره همان فهرست ۱، ۷، ۱۴، ۳۰، ۹۰، ۱۸۰ و ۳۶۵ روز بهعلاوه «همه زمانها» را دارد، و کنار آنها گزینه «بین دو تاریخ» که تقویم جلالی را باز میکند و from و to را مینویسد. کوهورت، یعنی «کسانی که اولین بار X را بین این دو روز انجام دادند»، تنها چیزی است که با پنجره نسبی گفته نمیشود و به همین دلیل این گزینه وجود دارد.
تقویم در این حالت میانبر نسبی نشان نمیدهد. میانبرها بازهای میدهند که مدام جابهجا میشود و این کنترل باید دو تاریخ ثابت بدهد، پس گزینهای که خروجیاش باید فورا منجمد شود اصلا پیشنهاد نمیشود.
before و after هنوز فقط از راه API نوشته میشوند.
تله: نام رویداد غلط، کامپایل تمیز و مخاطب صفر
نام رویداد در برابر هیچ فهرستی بررسی نمیشود. کامپایلر فقط چک میکند که تهی نباشد و از ۱۲۸ بایت بلندتر نباشد، بعد آن را بهعنوان پارامتر بایند میکند. order_completd یک SQL کامل و معتبر تولید میکند که صفر سطر برمیگرداند، و این از یک مخاطب واقعی صفر قابل تشخیص نیست.
همین تله برای نام ویژگی پرونده و نام ویژگی رویداد هم برقرار است، در properties و در aggregate.
چرا اینطور طراحی شده: مشتریها مدام ویژگی و رویداد خودشان را تعریف میکنند و رد کردن نامهای ناشناس این قابلیت را بیمصرف میکرد. سابقهاش هم هست: ویژگی national_id تمام مدت ذخیره میشد ولی از فهرست ستونها جا افتاده بود، پس هر سگمنتی روی آن، برای هر مشتری، هیچکس را برنمیگرداند و هیچ خطایی هیچجا نبود.
راه چاره این است که نامها را نپرسید، بخوانید.
curl https://api.segmentic.net/v1/schema/events \
-H "Authorization: Bearer sk_seg_..."
{
"events": [
{"name": "order_completed", "volume": 812443, "prop_keys": ["revenue", "category", "coupon"], "last_seen": "2026-08-06"},
{"name": "product_viewed", "volume": 4192010, "prop_keys": ["sku", "category"], "last_seen": "2026-08-07"}
]
}
فهرست به ترتیب حجم مرتب است. last_seen مفیدترین ستون این پاسخ است: رویدادی با حجم بزرگ و last_seen سه هفته پیش یعنی یکپارچهسازیای که خراب شده، و هیچ عدد دیگری این را نمیگوید. حجم بهتنهایی تا یک ماه بعد هم سالم به نظر میرسد، چون پنجرهاش ۹۰ روز است.
برای ویژگیهای پرونده:
curl https://api.segmentic.net/v1/schema/traits \
-H "Authorization: Bearer sk_seg_..."
{
"traits": ["city", "loyalty_tier", "gc_key_balance"],
"schema": [
{"name": "city", "kind": "string", "users": 114233},
{"name": "loyalty_tier", "kind": "string", "users": 40112},
{"name": "gc_key_balance", "kind": "number", "users": 98004}
]
}
kind میگوید ویژگی در کدام نگاشت زندگی میکند، و همان چیزی است که تعیین میکند «بیشتر از ۵۰۰۰۰۰۰» و «برابر ۵۰۰۰۰۰۰» روی چه ستونی کامپایل میشوند. users تعداد پروندههایی است که این ویژگی را دارند؛ ویژگیای که سه نفر دارند به احتمال زیاد آن چیزی نیست که فکر میکنید.
هر دو مسیر مجوز event.read میخواهند.
بعد از ساختن فیلتر، آن را با POST /v1/audiences/validate بخوانید و جمله فارسی برگشتی را با آنچه در سرتان بود مقایسه کنید. کسی که بهجای مشهد «تهران» میخواند، باگش را قبل از خرج کردن یک کوئری پیدا کرده است.
هشت مثال کامل
هر مثال، JSON کامل است بهعلاوه جملهای که سرور برمیگرداند. جمله فارسی چیزی است که POST /v1/audiences/validate در فیلد description_fa میدهد. جمله انگلیسی چیزی است که پنل در حالت انگلیسی نشان میدهد؛ API عمومی همیشه فارسی جواب میدهد، چون میانافزار زبان روی آن سوار نیست و پیشفرض فارسی است.
مقدارها ترجمه نمیشوند. هر رشتهای که در فیلتر بنویسید، همانطور که هست در جمله برمیگردد.
سبد رها شده
{
"definition": {
"version": 1,
"root": {
"kind": "group",
"op": "and",
"children": [
{
"kind": "event",
"event": "product_added_to_cart",
"count": {"operator": "gte", "value": 1},
"window": {"kind": "last", "amount": 7, "unit": "day"}
},
{
"kind": "event",
"event": "order_completed",
"negate": true,
"count": {"operator": "gte", "value": 1},
"window": {"kind": "last", "amount": 7, "unit": "day"}
}
]
}
}
}
{"valid": true, "description_fa": "کاربرانی که در ۷ روز گذشته «افزودن به سبد» را حداقل یک بار انجام دادهاند و در ۷ روز گذشته «خرید» انجام ندادهاند"}
انگلیسی، از پنل:
Users who in the last 7 days did “Added to cart” at least once and in the last 7 days did not do “Purchase”
خریدار تهرانی که اپ را باز نکرده
{
"definition": {
"version": 1,
"root": {
"kind": "group",
"op": "and",
"children": [
{
"kind": "event",
"event": "order_completed",
"count": {"operator": "gte", "value": 3},
"window": {"kind": "last", "amount": 30, "unit": "day"}
},
{"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}},
{"kind": "event", "event": "app_opened", "negate": true}
]
}
}
}
{"valid": true, "description_fa": "کاربرانی که در ۳۰ روز گذشته «خرید» را حداقل ۳ بار انجام دادهاند و شهر آنها «تهران» است و «باز کردن اپ» انجام ندادهاند"}
رویداد سوم پنجره ندارد، پس «هیچوقت اپ را باز نکردهاند» معنی میدهد، نه «در سی روز گذشته باز نکردهاند».
مجموع خرید در نود روز
{
"definition": {
"version": 1,
"root": {
"kind": "event",
"event": "order_completed",
"window": {"kind": "last", "amount": 90, "unit": "day"},
"aggregate": {"function": "sum", "property": "revenue", "operator": "gte", "value": 2000000}
}
}
}
{"valid": true, "description_fa": "کاربرانی که در ۹۰ روز گذشته مجموع مبلغ «خرید» آنها حداقل ۲٬۰۰۰٬۰۰۰ است"}
Users who in the last 90 days have a total amount for “Purchase” that is at least 2,000,000
اینجا ریشه یک گره رویداد است، نه گروه. این معتبر است.
عددها در جمله فارسی با ارقام فارسی و جداکننده هزارگان عربی (U+066C) نوشته میشوند، چون در ایران مبلغ اینطور خوانده میشود.
تهرانیهای قابل دسترسی، با گروه تودرتو
{
"definition": {
"version": 1,
"root": {
"kind": "group",
"op": "and",
"children": [
{"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}},
{
"kind": "group",
"op": "or",
"children": [
{"kind": "trait", "trait": "has_push", "operator": "eq", "value": {"type": "bool", "bool": true}},
{"kind": "trait", "trait": "has_email", "operator": "eq", "value": {"type": "bool", "bool": true}}
]
}
]
}
}
}
{"valid": true, "description_fa": "کاربرانی که شهر آنها «تهران» است و (قابلیت دریافت پوش دارند یا داشتن ایمیل دارند)"}
فقط گروه تودرتو پرانتز میگیرد؛ سطح بالا بدون پرانتز بهتر خوانده میشود. این تعریف را پنل هم میتواند بسازد.
عضو یک فهرست ثابت
{
"definition": {
"version": 1,
"root": {"kind": "segment", "segment_id": 1234, "in_segment": true}
}
}
{"valid": true, "description_fa": "کاربرانی که عضو سگمنت شماره ۱٬۲۳۴ هستند"}
اگر "in_segment": true را بردارید، معنی وارونه میشود:
کاربرانی که عضو سگمنت شماره ۱٬۲۳۴ نیستند
این تنها فیلدی در کل این زبان است که نبودنش شرط را وارونه میکند.
خرید بالای پانصد هزار در یک دسته
{
"definition": {
"version": 1,
"root": {
"kind": "event",
"event": "order_completed",
"window": {"kind": "last", "amount": 7, "unit": "day"},
"properties": [
{"property": "revenue", "operator": "gt", "value": {"type": "number", "num": 500000}},
{"property": "category", "operator": "eq", "value": {"type": "string", "str": "موبایل"}}
]
}
}
}
{"valid": true, "description_fa": "کاربرانی که در ۷ روز گذشته «خرید» انجام دادهاند که مبلغ آن بیشتر از ۵۰۰٬۰۰۰ باشد و category آن «موبایل» باشد"}
revenue تنها ویژگی رویداد است که نام فارسی دارد و «مبلغ» خوانده میشود. هر کلید دیگری خام در وسط جمله فارسی مینشیند، چون برای فیلدی که مشتری اختراع کرده ترجمهای وجود ندارد و ساختن ترجمه از نشان دادن چیزی که خودش تایپ کرده بدتر است.
در آستانه ریزش
{"definition": {"version": 1, "root": {"kind": "churn", "band": "high"}}}
{"valid": true, "description_fa": "کاربرانی که در گروه «ریسک ریزش بالا» هستند"}
این و «خاموششدهها» دو مخاطبی هستند که روز اول یکپارچهسازی هم کار میکنند، چون به نام هیچ رویدادی وابسته نیستند.
ده پیام پشتسرهم بیپاسخ
{
"definition": {
"version": 1,
"root": {
"kind": "engagement",
"metric": "ignored_streak",
"operator": "gte",
"value": {"type": "number", "num": 10}
}
}
}
{"valid": true, "description_fa": "کاربرانی که پیامهای بیپاسخ پشتسرهم آنها حداقل ۱۰ است"}
شکل سنجه همان قالب جملهای را به کار میبرد که هر مقایسه عددی دیگری به کار میبرد، تا شرط تعامل مثل بقیه جمله خوانده شود و نه مثل چیزی که کنارش پیچ شده. در انگلیسی همین بازاستفاده باعث میشود حرف تعریف کمی ناجور بنشیند.
سنجیدن مخاطب: کدام فراخوانی چه چیزی میدهد
شش مسیر اینجا هست و فقط دوتای اول از اینترنت قابل دسترسیاند.
| مسیر | میزبان | مجوز | چه میدهد | هزینه |
|---|---|---|---|---|
POST /v1/audiences/validate | api.segmentic.net | segment.read | معتبر بودن و جمله فارسی | ۱ واحد |
POST /v1/audiences/count | api.segmentic.net | segment.read | شمارش دقیق | ۲۵ واحد |
POST /v1/segments/estimate | فقط پنل | segment.read | تخمین نمونهگیریشده | ندارد |
POST /v1/segments/preview | فقط پنل | profile.read | چند پرونده واقعی | ندارد |
POST /v1/segments/identifier-preview | فقط پنل | profile.read | تعداد تطبیق و حداکثر ۱۰۰ پرونده برای شناسه، ایمیل یا شماره | ندارد |
POST /v1/segments/describe | فقط پنل | segment.read | فقط جمله | ندارد |
«فقط پنل» یعنی این مسیرها روی صفحه کنترل داشبورد ثبت شدهاند، و آن پورت به عمد از اینترنت مسیریابی نمیشود. پروکسی فقط میزبان عمومی را به شنونده دوم API میبرد. پس نمیتوانید POST /v1/segments/preview را از سرور خودتان صدا بزنید؛ آن دکمه پیشنمایش داخل پنل است.
پیشنمایش شناسهها بدنهای مثل {"identifiers":["09123456789","user_42"],"limit":100} میگیرد. حداکثر پنجاه هزار ورودی را در یک کوئری محدود به همان tenant تطبیق میدهد و count، users، unmatched و در صورت بریده شدن ورودی truncated را برمیگرداند. این مسیر چیزی در عضویت سگمنت نمینویسد.
پنج مسیر دیگر بدنه یکسانی میخواهند:
{"definition": { }, "limit": 10}
limit را فقط preview میخواند.
validate
curl -X POST https://api.segmentic.net/v1/audiences/validate \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
{"valid": true, "description_fa": "کاربرانی که شهر آنها «تهران» است"}
فیلتر نامعتبر با کد وضعیت ۴۲۲ و پاکت خطای API عمومی برمیگردد:
{"error": {"code": "filter_invalid", "message": "segment: group has no children"}}
این عمدی است و با نسخه داخل پنل فرق دارد: پنل به فیلتر نامعتبر ۲۰۰ با valid: false جواب میدهد، که برای فرمی که کاربر همان لحظه در آن تایپ میکند درست است و برای یکپارچهسازیای که مدیریت خطایش روی کد وضعیت شاخه میزند غلط.
هیچ دیتابیسی لمس نمیشود. این ارزانترین راه برای اطمینان از اینکه فیلترتان همان چیزی است که فکر میکنید.
count
curl -X POST https://api.segmentic.net/v1/audiences/count \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
{"count": 114233, "approximate": false, "description": "کاربرانی که شهر آنها «تهران» است", "took_ms": 812}
اینجا سه چیز غافلگیرکننده هست و هر سه از یک ریشه میآیند: این مسیر همان handler پنل را دوباره به کار میگیرد.
- نام فیلد
descriptionاست، نهdescription_fa، ولی محتوایش همیشه فارسی است. - خطاهایش پاکت مسطح پنل را دارند، نه پاکت API عمومی. فیلتر نامعتبر یعنی
400 {"error": "segment: ..."}و خرابی انبار داده یعنی503 {"error": "count unavailable"}. این با وعده «یک شکل خطا در همهجا» روی API عمومی نمیخواند. approximateهمیشهfalseاست.
هزینهاش ۲۵ واحد از بودجه است، چون یک اسکن کامل FINAL روی پروندههای شماست بهعلاوه هر زیرکوئری. مهلتش ۳۰ ثانیه است.
هیچ محدودکننده همزمانی روی این مسیر وجود ندارد و هیچ ردی از «پنجره بیکران» گرفته نمیشود: فیلتری با شرط رویدادی بدون window کامپایل و اجرا میشود.
estimate
شمارنده زنده زیر سازنده سگمنت در پنل. با هش کردن شناسه کاربر نمونه میگیرد و یکی از هر ۱۰۰ را میخواند، بعد عدد را ضرب میکند.
{"count": 2400000, "approximate": true, "sample_rate": 100, "description": "کاربرانی که شهر آنها «تهران» است", "took_ms": 41}
مهلتش ۳ ثانیه است و در صورت سررسید 503 {"error": "estimate unavailable"} میدهد؛ منتظر نمیماند.
یک بازگشت به شمارش دقیق دارد: اگر عدد نمونهگیریشده زیر ۳۰۰۰ باشد (یعنی زیر ۳۰ سطر واقعی در نمونه) کوئری دقیق دوباره اجرا میشود و پاسخ با approximate: false و بدون sample_rate برمیگردد. دلیلش این است که مخاطب ۴۰ نفره با نمونه یک در صد صفر خوانده میشود، و صفر غلط بدتر از عدد تقریبی است.
preview
مجوزش profile.read است نه segment.read، چون این مسیر نام و شماره موبایل و شهر آدمهای واقعی را برمیگرداند.
{"users": [{"user_id": "u_1", "email": "ali@example.ir", "phone": "+989120000000", "first_name": "علی", "city": "تهران", "last_seen": "2026-08-01T09:00:00Z"}]}
limit پیشفرض ۱۰ است و سقف ۱۰۰. سقف عمدی است، نه فقط پیشفرض: احترام گذاشتن به limit بدون سقف، «پیشنمایش» را به خروجی انبوه فهرست مشتری تبدیل میکند که هر کسی با profile.read به آن میرسد، و در لاگ ممیزی از نگاه انداختن به ده سطر قابل تشخیص نیست. بردن داده بیرون از ساختمان مجوز data.export است که جداست.
مرتبسازی last_seen DESC است، پس تازهترین تطابقها را میبینید نه نمونه تصادفی. فیلدهای تماس پوشانده نمیشوند.
describe
فقط جمله را برمیگرداند و به هیچ دیتابیسی دست نمیزند، به همین دلیل سازنده سگمنت میتواند با هر ضربه کلید صدایش بزند.
{"description": "کاربرانی که شهر آنها «تهران» است"}
زبانش از هدر Accept-Language میآید، چون میانافزار زبان روی mux پنل سوار است.
این مسیر اعتبارسنجی نمیکند. تعریفی که کامپایل نمیشود هم جمله میگیرد، و تعریف تهی جمله همه کاربران میگیرد. یعنی describe و validate درباره تعریف تهی با هم اختلاف دارند: describe میگوید «همه کاربران» و validate آن را رد میکند.
سگمنت ذخیرهشده: ساخت، ویرایش، حذف
سگمنت ذخیرهشده یک سطر در پستگرس است با یک نام یکتا در هر حساب.
{
"id": 11,
"name": "خریداران تهران",
"kind": "dynamic",
"definition": {"version": 1, "root": { }},
"description_fa": "کاربرانی که شهر آنها «تهران» است",
"last_size": 0,
"updated_at": "2026-08-07T11:20:00Z"
}
description_fa همیشه سمت سرور محاسبه و در زمان ذخیره کش میشود؛ هر چیزی که در این فیلد بفرستید دور ریخته میشود.
سه نوع وجود دارد:
dynamic، پیشفرض. هیچچیز مادی نمیشود. تعریف هر بار که کسی میشمارد یا کمپینی صفحهبندی میکند، تازه اجرا میشود.static. عضویت، سطرهایی است که کسی وارد کرده. تعریفش کامپایل نمیشود و میتواند فقط{"version": 1}باشد.realtime. هم API و هم قید دیتابیس این مقدار را میپذیرند و هیچچیزی در بکاند آن را پیادهسازی نکرده است. تنها کدی که خاص برخوردش میکند، نوشتن مستقیم عضویت را دقیق مثلdynamicرد میکند. رزروشده حسابش کنید، نه کارآمد.
نوع بعد از ساخت قابل تغییر نیست. بهروزرسانی، name و definition و description_fa را مینویسد و kind را به عمد دست نمیزند. تبدیل یک مخاطب ذخیرهشده از ثابت به پویا، عضویتش را در محاسبه بعدی بیسروصدا دور میریخت، و تبدیل برعکس، کوئریای را منجمد میکرد که کسی هنوز فکر میکند زنده است.
از API مدیریت
| متد و مسیر | مجوز | پاسخ موفق |
|---|---|---|
GET /v1/segments | segment.read | {"segments": [...]} |
GET /v1/segments/{id} | segment.read | شیء سگمنت |
POST /v1/segments | segment.write | ۲۰۱ |
PUT /v1/segments/{id} | segment.write | ۲۰۰ |
DELETE /v1/segments/{id} | segment.delete | ۲۰۴ بدون بدنه |
بدنه نوشتن فقط دو فیلد دارد:
curl -X POST https://api.segmentic.net/v1/segments \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{"name":"خریداران تهران","definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
{"id": 11, "name": "خریداران تهران", "description_fa": "کاربرانی که شهر آنها «تهران» است"}
فیلد kind روی این بدنه وجود ندارد، پس هر سگمنتی که API مدیریت بسازد dynamic است. فهرست ثابت را از این راه نمیشود ساخت.
خطاها:
| وضعیت | کد | چه وقت |
|---|---|---|
| ۴۰۰ | name_required | name تهی یا فقط فاصله |
| ۴۰۰ | bad_id | شناسه در مسیر عدد مثبت نیست |
| ۴۰۰ | malformed_json | بدنه JSON معتبر نیست |
| ۴۲۲ | filter_invalid | تعریف کامپایل نمیشود، با متن دقیق کامپایلر |
| ۴۰۴ | not_found | روی PUT: شناسه ناشناس، یا شناسه مشتری دیگر |
| ۵۰۳ | segment_unavailable | ذخیره یا حذف ممکن نشد |
در PUT، خواندن قبل از نوشتن عمدی است تا شناسهای از URL مشتری دیگر ۴۰۴ بدهد و نه نوشتنی که بیسروصدا یک سگمنت روی حساب شما میسازد. name تهی در PUT یعنی «نام فعلی را نگه دار».
دو مسیر از این جدول مثل بقیه رفتار نمیکنند و دلیل هر دو یکی است: اینها handler پنلاند نه handler API مدیریت.
GET /v1/segments/{id} خطای ۴۰۴ خودش را در پاکت مسطح میدهد، {"error": "segment not found"}، بدون فیلد code. روی این مسیر error را هم رشته و هم شیء در نظر بگیرید.
DELETE /v1/segments/{id} اصلا ۴۰۴ نمیدهد. بایگانی یک UPDATE ... WHERE tenant_id = $1 AND id = $2 AND archived_at IS NULL است و تعداد سطر تغییرکرده خوانده نمیشود، پس حذف شناسهای که وجود ندارد، شناسهای که مال حساب دیگری است، و شناسهای که قبلا حذفش کردهاید، هر سه دقیق مثل یک حذف واقعی 204 میگیرند. هیچچیز در پاسخ این سه را از هم جدا نمیکند. اگر برایتان مهم است که سگمنت واقعا آنجا بوده، اول GET بگیرید.
سه چیزی که وجود ندارد و شاید انتظارش را داشته باشید. هیچ If-Match و هیچ نشانه نسخهای نیست، پس دو نویسنده همزمان بیسروصدا روی هم مینویسند. هیچ کلید یکتاسازی درخواست پذیرفته نمیشود. و حذف هیچوقت بهخاطر «در حال استفاده» رد نمیشود: حذف مخاطبی که یک کمپین زمانبندیشده به آن اشاره میکند، موفق میشود.
حذف در واقع بایگانی است. سطر میماند و فقط archived_at پر میشود. کمپینی که پیش از این اجرا شده به این تعریف ارجاع میدهد، و گزارشی که نمیتواند بگوید یک ارسال به چه کسانی رفته، از یک فهرست کمی بلندتر بدتر است.
GET /v1/segments روی API مدیریت هم صفحهبندی ندارد. سقف ثابت ۲۰۰ سطر مرتب بر updated_at نزولی است و پارامترهای limit و cursor نادیده گرفته میشوند. چون همان handler پنل است، پاکت صفحهبندی استاندارد را هم ندارد و پاسخ {"segments": [...]} است. تعریف کامل هر سگمنت در هر سطر فهرست میآید.
این سقف بیسروصداست و همین از خود سقف بدتر است. در پاسخ نه has_more هست، نه next_cursor و نه تعداد کل. حسابی که ۲۵۰ سگمنت دارد ۲۰۰ تای تازهتر را میبیند و هرچه بگردد باز همان ۲۰۰ تاست. روی هیچکدام از دو سطح مسیری وجود ندارد که به آن ۵۰ تای دیگر برسد. تنها چیزی که یکی از آنها را برمیگرداند توی دید، ویرایش کردنش است، چون ذخیره updated_at را مینویسد. اگر بیشتر از ۲۰۰ مخاطب دارید، فهرست شناسههایشان را خودتان نگه دارید: GET /v1/segments/{id} هرکدام را با شناسه میآورد و سقف ندارد.
از پنل
پنل چهار مسیر جداگانه دارد که فقط از خود پنل قابل دسترسیاند. تفاوتهای معنادارشان با API مدیریت:
POST /v1/segmentsیک upsert است:idغیرصفر یعنی بهروزرسانی،idغایب یعنی ساخت. پاسخ در هر دو حالت200 {"id": 11}است، نه ۲۰۱.- فیلد
kindرا میپذیرد، پس فهرست ثابت فقط از این راه ساخته میشود. مقدار خارج از سهتایی مجاز یعنی400 {"error": "unknown segment kind"}. - برای نوع
static، جمله توصیفی با یک متن ثابت جایگزین میشود: «فهرست دستی، اعضا را خودتان اضافه میکنید». اگر این کار را نمیکرد، تعریف تهی به «همه کاربران» توصیف میشد و یک فهرست دستی چهل هزار نفره روی صفحه بهعنوان کل پایگاه کاربران برچسب میخورد. - نام تکراری خطای قید یکتایی میدهد که به شکل
503 {"error": "could not save segment"}برمیگردد، نه ۴۰۹ و نه ۴۰۰ راهنما. - سقف بدنه
1 MiBاست، در حالی که روی API مدیریت8 MiBاست.
فهرست ثابت و اعضایش
فهرست ثابت جایی است که کسی آدمها را در آن گذاشته: فایل اکسل یک آژانس، گزارش تسویه، برندههای یک قرعهکشی. سه مسیر دارد و هیچکدام روی API مدیریت نیستند؛ فقط از پنل.
| متد و مسیر | مجوز |
|---|---|
GET /v1/segments/{id}/members | segment.read |
POST /v1/segments/{id}/members | segment.write |
DELETE /v1/segments/{id}/members/{user_id} | segment.write |
GET فقط {"size": 4670} میدهد و نوع سگمنت را بررسی نمیکند، پس یک سگمنت پویا اینجا size: 0 گزارش میشود و نه خطا.
بدنه افزودن یک فیلد دارد و شناسه کاربر و شماره موبایل و ایمیل را قاطی میپذیرد:
{"identifiers": ["09123456789", "ali@example.ir", "u-42", "۰۹۱۲۳۴۵۶۷۸۹"]}
قاطی بودن عمدی است: یک فایل اکسل یک ستون دارد و بازاریاب میداند کدام است؛ پرسیدنش یک فیلد اضافه میشد که اشتباه پر میکنند. ارقام فارسی به لاتین و شمارهها به E.164 نرمال میشوند. هر مقدار اول بهعنوان شناسه کاربر و بعد بهعنوان شماره یا ایمیل امتحان میشود، چون مشتریای که شناسه کاربرهایش شماره موبایل است در ایران بهاندازه کافی رایج هست.
پاسخ:
{"added": 38210, "unmatched": ["09120000000"], "truncated": false, "size": 41902}
- سقف هر درخواست ۵۰۰۰۰ شناسه است. بیشتر از آن بریده میشود و
truncated: trueبرمیگردد. فایل بزرگتر از راه ورودی CSV میرود. unmatchedهمان شناسهها را برمیگرداند، نه شمارششان، چون «۳۴۱۲ تا از ۴۰۰۰۰ نخورد» عددی است که کسی باید رویش کاری بکند و نمیتواند؛ او سطرها را لازم دارد تا با فایل خودش بسنجد. سقف این فهرست ۱۰۰ عضو است.- شناسهای که به بیش از یک پرونده بخورد رد میشود و در
unmatchedگزارش میشود. - تکراریهای داخل یک درخواست روی هم میافتند.
- افزودن به سگمنتی که ثابت نیست:
409با متن «این سگمنت با شرط تعریف شده است؛ فقط به فهرست ثابت میشود کاربر اضافه کرد». - فهرست تهی:
400با متن «فهرست خالی است».
حذف یک عضو، یک mutation از نوع ALTER TABLE ... DELETE روی ClickHouse است و طبق طراحی کند است.
بازمحاسبه عضویت
برای سگمنت پویا هیچ عضویت ذخیرهشدهای وجود ندارد و هیچ کار بازمحاسبهای هم وجود ندارد. تعریف، همان سگمنت است و هر بار تازه اجرا میشود.
نتیجههای عملی این تصمیم:
last_sizeهمیشه صفر است وlast_computed_atهمیشه غایب. تابعی که این دو را مینویسد در کد هست و هیچ فراخوانیای ندارد، پس روی نصب واقعی این ستونها تا ابد خالی میمانند. کارت سگمنت در پنل به همین دلیل همیشه «در انتظار محاسبه» مینویسد و انتخابگر مخاطب کمپین هیچوقت تعداد نفرات را نشان نمیدهد.- ستون
refresh_cronدر اسکیمای دیتابیس هست و هیچ کدی آن را نه میخواند و نه مینویسد. زمانبندی بازمحاسبه وجود ندارد. - مصرفکنندهها تعریف را زنده حل میکنند. کمپین در لحظه ارسال، کوئری کامپایلشده را صفحهبهصفحه میخواند و اندازهاش را با تخمین میگیرد و زیر ۵۰۰۰ نفر به شمارش دقیق برمیگردد. شرط داخل سناریو تعریف را روی یک شناسه کاربر باریک میکند و میشمارد.
تنها جایی که عضویت یک سگمنت پویا بهخاطر سپرده میشود، تریگر سناریو است. اسکنر سگمنت را صفحهبهصفحه میخواند، با اسکن قبلی تفاضل میگیرد و همان تفاضل را وارد سناریو میکند: segment_enter تازهواردها را و segment_exit خارجشدهها را. اولین اسکن بعد از انتشار سناریو فقط عضویت را ثبت میکند و هیچکس را وارد نمیکند، وگرنه انتشار یک بازگردانی برای ۴۰۰۰۰۰ مشتری خفته یعنی هر ۴۰۰۰۰۰ نفر در پنج دقیقه بعد پیام میگیرند. سگمنت بزرگتر از ۲۵۰۰۰۰ عضو بریده میشود و بریدگی هم در لاگ و هم روی سطر وضعیت تریگر ثبت میشود.
برای فهرست ثابت، عضویت همان سطرهایی است که نوشتهاید. جدول یک ReplacingMergeTree است که روی یک ستون نسخه با دقت نانوثانیه کلید خورده و هر خواندنی FINAL دارد، پس افزودن تکراری روی هم میافتد.
محدودیتها و پیشفرضها
| چیز | مقدار |
|---|---|
| بیشترین عمق تودرتویی | ۸ (ریشه عمق صفر است، پس ۹ سطح) |
| بیشترین تعداد گره | ۲۰۰، شامل هر عضو properties |
بیشترین اعضای فهرست in | ۱۰۰۰ |
| بیشترین طول نام ویژگی، رویداد و کلید | ۱۲۸ بایت، حدود ۶۴ حرف فارسی |
amount در پنجره نسبی | از ۱ تا ۱۰۰۰۰ |
| نرخ نمونهگیری تخمین | یک در ۱۰۰ |
| مهلت تخمین | ۳ ثانیه |
| مهلت کوئری (شمارش، پیشنمایش، ذخیره) | ۳۰ ثانیه |
| آستانه بازگشت به شمارش دقیق | تخمین زیر ۳۰۰۰ |
| سطرهای پیشنمایش | پیشفرض ۱۰، سقف ۱۰۰ |
| شناسه در هر درخواست افزودن عضو | ۵۰۰۰۰ |
| شناسههای نخورده در پاسخ | ۱۰۰ |
| سقف بدنه روی API مدیریت | 8 MiB |
| سقف بدنه روی مسیرهای پنل | 1 MiB |
| سقف فهرست سگمنتهای ذخیرهشده | ۲۰۰ سطر، بدون صفحهبندی |
| سقف اسکن تریگر سناریو | ۲۵۰۰۰۰ عضو |
| هزینه بودجه: validate و CRUD سگمنت | ۱ واحد |
| هزینه بودجه: count | ۲۵ واحد |
مجوزهای مربوط: segment.read، segment.write، segment.delete، profile.read، event.read. نقشهای مالک، ادمین و بازاریاب هر سه مجوز سگمنت را دارند. تحلیلگر، بیننده و تأییدکننده فقط segment.read دارند. بیننده profile.read ندارد، پس میتواند مخاطب را بشمارد ولی نمیتواند پیشنمایشش را ببیند. جزئیات محدودیت نرخ در سقفها است.
متن دقیق خطاهای کامپایلر
این نه خطا همه چیزی هستند که کامپایل میتواند برگرداند. متنشان انگلیسی است و ترجمه نمیشود، و بیکموکاست در فیلد message به شما میرسد.
| متن پایه | چه وقت |
|---|---|
segment: unknown node kind | kind تهی یا ناشناس |
segment: group has no children | آرایه children خالی |
segment: nesting too deep | بیشتر از ۹ سطح |
segment: too many conditions | بیشتر از ۲۰۰ گره و شرط ویژگی |
segment: invalid identifier | نام ویژگی، رویداد، کلید یا تابع تجمیع نامعتبر؛ یا segment_id صفر؛ یا گروه یا سنجه ناشناخته |
segment: unsupported operator | عملگر خارج از فهرست، یا عملگر غیرعددی روی تعامل و ریزش، یا هر چیزی جز is_set روی birthday |
segment: operator requires a value | value نیامده، یا فهرست in تهی است |
segment: invalid time window | kind یا unit ناشناخته، amount بیرون از بازه، یا کرانهای ناقص |
segment: list has too many values | بیشتر از ۱۰۰۰ عضو در فهرست |
اغلبشان با مقدار مقصر بستهبندی میشوند:
segment: unknown node kind: "wat"
segment: invalid identifier: trait " "
segment: invalid time window: unit "fortnight"
segment: unsupported operator: engagement needs a numeric operator, got "contains"
اینها کد ماشینی پایدار نیستند. تنها کد پایداری که روی API مدیریت به آن تکیه کنید filter_invalid در پاکت خطاست. شرح کامل پاکت در خطاها است.
چیزهایی که وجود ندارند
هر کدام از اینها چیزی است که یک مشتری منطقی دنبالش میگردد و در این محصول نیست. جمله موجه بهجای این فهرست، یک بعدازظهر از وقت شما میگرفت.
- مقایسه تاریخ روی مقدار یک ویژگی.
type: "date"پذیرفته و نادیده گرفته میشود. بهجایشdays_until_birthdayوdays_until_signup_anniversary. - توابع
firstوlastدر تجمیع، و هر راهی برای شرط گذاشتن روی مقدار ویژگی در اولین یا آخرین رخداد یک رویداد. نزدیکترین چیز موجودdays_since_last_seenوdays_since_last_orderاست که تازگی را جواب میدهند نه مقدار را. - مقایسه متنی دو ویژگی با هم.
compare_traitهر دو طرف را عدد میگیرد و ستون متنی در هر طرف با نام خودش رد میشود. در مقایسه دو ویژگی. - شرط ترتیبی («اول A بعد B»). شرطهای رویداد زیرکوئریهای مستقلاند که با AND به هم وصل میشوند.
- شمارش مقدارهای یکتا.
count()سطر میشمارد. - منطقه زمانی روی پنجره. همهچیز UTC است.
- تاریخ جلالی روی سیم. صریح رد شده است؛ جلالی فقط در جمله توصیفی.
notروی گرهtrait،eventیاsegment. فقط گروه و تعامل و ریزش این فیلد را میخوانند.- OR بین شرطهای ویژگی یک رویداد. همیشه AND.
- کار بازمحاسبه عضویت برای سگمنت پویا.
- پیادهسازی نوع
realtime. مقدار پذیرفته و ذخیره میشود و هیچچیز به آن عمل نمیکند. - ساخت فهرست ثابت از API مدیریت. بدنهاش فیلد
kindندارد. - مسیرهای عضویت فهرست ثابت روی API مدیریت. فقط پنل.
- مسیر تخمین و مسیر پیشنمایش فیلتر موقت روی API مدیریت.
- صفحهبندی روی
GET /v1/segmentsروی هیچکدام از دو سطح. - همزمانی خوشبینانه روی نوشتن سگمنت. نه
If-Match، نه نشانه نسخه. - رد کردن حذف یا ویرایش سگمنتی که در حال استفاده است.
- کلید یکتاسازی روی ساخت سگمنت.
- فهرست قالبهای آماده روی سرور. ۹ قالب پنل ثابتهای TypeScript داخل بسته مرورگرند و هیچ مسیری آنها را برنمیگرداند.
- اعتبارسنجی نام رویداد در زمان کامپایل. همان تله است.
- ویرایشگر ویژگی رویداد، ویرایشگر تجمیع، مقایسه یک ویژگی با ویژگی دیگر، و شرط عضویت در سگمنت، در پنل. هر چهار در زبان هستند و فقط از API نوشته میشوند.
اگر با ایجنت هوش مصنوعی کار میکنید، سه ابزار MCP برای همین صفحه وجود دارد: فهرست مخاطبها، توصیف یک فیلتر و شمارش یک فیلتر. جزئیات در MCP.