Advanced
For logged-out traffic you need a stable unit so a fractional rollout buckets the same on the server and in the browser. The middleware mints a first-party…
/docs/ — also served raw at https://shipeasy-ai.github.io/sdk-python/pages/advanced.md.Anonymous-id bucketing + middleware
For logged-out traffic you need a stable unit so a fractional rollout buckets
the same on the server and in the browser. The middleware mints a first-party
__se_anon_id cookie (shared with every Shipeasy SDK) for any request without
one; evaluations then default to it as anonymous_id, so get_flag on an
anonymous request just works — no per-call wiring.
# WSGI (Flask, Django, ...)
from shipeasy.middleware import AnonIdMiddleware
app.wsgi_app = AnonIdMiddleware(app.wsgi_app)
# ASGI (FastAPI, Starlette)
from shipeasy.middleware import AnonIdASGIMiddleware
app.add_middleware(AnonIdASGIMiddleware)# logged-out request → buckets on the __se_anon_id cookie automatically
client = shipeasy.Client({})
client.get_flag("new_checkout")An explicit user_id/anonymous_id always wins. The id is also on the request
(environ["shipeasy.anon_id"]). The cookie is non-HttpOnly by design so the
browser SDK buckets identically; a request with no unit still resolves a
fully-rolled (100%) gate as on. Cookie name + format are a cross-SDK contract.
Private attributes
Pass private_attributes to configure() to strip the named
keys from every outbound event properties bag before it POSTs to /collect
(LD/Statsig privateAttributes). The server evaluates locally, so private attrs
still drive targeting — they just never leave the process on the telemetry
path:
shipeasy.configure(api_key="sdk_server_...", private_attributes=["email", "ssn"])Sticky bucketing
Pass a sticky_store to configure() to pin a user's experiment assignment
across allocation changes. InMemoryStickyStore is built in; implement the
StickyBucketStore protocol (get(unit) / set(unit, exp, entry)) for a
durable backend:
from shipeasy import InMemoryStickyStore
shipeasy.configure(api_key="sdk_server_...", sticky_store=InMemoryStickyStore())Absent a store, bucketing is deterministic (MurmurHash3 over the unit).
Bucketing unit (bucketBy)
The bucketing unit per experiment is server-driven: an experiment can be
configured to bucket on a non-default attribute (e.g. company_id) in the
dashboard, and the SDK reads it from the experiment definition
(exp.bucketBy) — falling back to user_id then anonymous_id. Make sure that
attribute is present in the user map you pass.
Exposure on read
There is no manual exposure primitive. Reading an assignment is the exposure:
assign() is side-effect free, and an enrolled Assignment logs a single
exposure event the first time you read a param via .get() — on the same
bound Client, no user argument:
# construct once per callsite (cheap; binds the user)
client = shipeasy.Client(current_user)
a = client.universe("checkout").assign() # no exposure yet
color = a.get("button_color", "red") # first read → logs one exposure
peek = a.get("button_color", "red", exposure=False) # peek, logs nothingExposures are deduped per process (by unit + experiment + group) and durably
per (unit, experiment, group) server-side, so repeated reads — and repeated
assign()/get() across requests — don't spam the collector. No-op in
test/offline mode or when the unit isn't enrolled.
Server-side rendering (SSR)
Emit the request's evaluated flags as a declarative <script> tag so the
browser SDK has them on first paint. shipeasy.bootstrap_script_tag carries the
payload in data-* attributes (no key); the /sdk/runtime.js browser
runtime reads them, installs window.shipeasy, republishes
window.__SE_BOOTSTRAP for the npm client SDK and writes the __se_anon_id
cookie so the browser buckets identically to the server. Both helpers delegate to the engine
configured via configure() — you never touch it directly.
import shipeasy
user = {"user_id": "u_123"}
# Two tags for the document <head>. The PUBLIC client key (not the server key)
# goes on the i18n loader tag — and it comes from configure(), so the callsite
# does not repeat it.
head = shipeasy.bootstrap_script_tag(user, anon_id=anon_id) \
+ shipeasy.i18n_script_tag()Every argument is optional
All three tag helpers fall back to what configure() already set, so the bare
call is the normal one — pass an argument only to override the configured value
for that one tag:
| Helper | Signature | Defaults |
|---|---|---|
shipeasy.i18n_script_tag | (client_key=None, profile=None, *, base_url=None) | client_key, profile, cdn_base_url |
shipeasy.bootstrap_script_tag | (user=None, *, anon_id=None, i18n_profile=None, base_url=None) | anonymous request, no anon id, profile, cdn_base_url |
shipeasy.devtools_script_tag | (project_id=None, *, client_key=None, base_url=None, defer=True) | project_id, client_key, cdn_base_url |
shipeasy.configure(
api_key=os.environ["SHIPEASY_SERVER_KEY"],
client_key=os.environ["SHIPEASY_CLIENT_KEY"], # PUBLIC key, for the tags
project_id=os.environ["SHIPEASY_PROJECT_ID"], # for the devtools tag
profile="en:prod",
)A tag still renders when a value is missing (the browser bundle reports what it
needs), but the SDK logs a warning naming the configure() option to fill in —
once per option, not once per render.
Devtools overlay tag
shipeasy.devtools_script_tag() emits the hosted devtools overlay bundle —
nothing to install, no overlay code in your bundle. It reads the project id and
public client key off the tag and opens with Shift+Alt+S or on any page
loaded with ?se=1. It is deferred by default: a developer tool never belongs
on the critical rendering path.
head += shipeasy.devtools_script_tag()Adding it unconditionally is fine: the overlay only opens for someone with a signed-in Shipeasy session, so on a page where nobody has authenticated it renders nothing and says nothing. Gating it on your own staff or environment check is optional — worth it only if you'd rather the bundle not load for end users at all:
head += shipeasy.devtools_script_tag() if request.user.is_staff else ""Identity coherence (no anon→identified flip)
When you pass an identified user (one carrying user_id / email / targeting
traits, not just an anonymous_id), those traits also ride the tag as
data-user. The browser SDK adopts that identity on first paint — its flags
are already this user's, and a later identify() in the browser reconciles
idempotently (a matching call is a no-op, no extra /sdk/evaluate, no
flip). Because the server already evaluated the payload for this user, the
data-flags on the tag match what the client would compute, so there is no
anon→identified flip. An anonymous request (only anonymous_id, or no traits)
emits no data-user, so the tag carries no PII when there is no identity to
carry.
OpenFeature provider
The Python SDK ships an OpenFeature server provider, shipeasy.openfeature.ShipeasyProvider, so apps standardised on the CNCF OpenFeature API can plug…
Admin API client (optional) — `shipeasy.admin`
The base SDK evaluates flags, configs, and experiments (configure() + shipeasy.Client(user)). The Admin API client is a separate, optional surface for…