Shipeasy
SDKsReferencePython

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…

Generated from the SDK's own /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 nothing

Exposures 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:

HelperSignatureDefaults
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.

Was this page helpful?
Updated July 26, 2026

On this page