shipeasy 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shipeasy-0.3.0/.github/workflows/publish.yml +65 -0
- shipeasy-0.3.0/.gitignore +7 -0
- shipeasy-0.3.0/CHANGELOG.md +24 -0
- shipeasy-0.3.0/LICENSE +40 -0
- shipeasy-0.3.0/PKG-INFO +76 -0
- shipeasy-0.3.0/README.md +61 -0
- shipeasy-0.3.0/pyproject.toml +24 -0
- shipeasy-0.3.0/shipeasy/__init__.py +12 -0
- shipeasy-0.3.0/shipeasy/_anon_id.py +92 -0
- shipeasy-0.3.0/shipeasy/_client.py +214 -0
- shipeasy-0.3.0/shipeasy/_eval.py +142 -0
- shipeasy-0.3.0/shipeasy/_hash.py +49 -0
- shipeasy-0.3.0/shipeasy/_telemetry.py +67 -0
- shipeasy-0.3.0/shipeasy/middleware.py +87 -0
- shipeasy-0.3.0/tests/test_eval.py +31 -0
- shipeasy-0.3.0/tests/test_hash.py +20 -0
- shipeasy-0.3.0/tests/test_middleware.py +95 -0
- shipeasy-0.3.0/tests/test_telemetry.py +76 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# HARD RULE: never publish manually. Bump the `version` field in
|
|
4
|
+
# pyproject.toml, push to main, and this workflow handles PyPI.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: publish
|
|
12
|
+
cancel-in-progress: false
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
publish:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
id-token: write # PyPI Trusted Publishing
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.11"
|
|
26
|
+
|
|
27
|
+
- run: pip install build hatchling
|
|
28
|
+
|
|
29
|
+
- name: Run tests
|
|
30
|
+
run: |
|
|
31
|
+
pip install pytest -e .
|
|
32
|
+
pytest -q
|
|
33
|
+
|
|
34
|
+
- name: Read package version
|
|
35
|
+
id: ver
|
|
36
|
+
run: |
|
|
37
|
+
VER=$(python -c "import tomllib,sys; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
38
|
+
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
|
39
|
+
|
|
40
|
+
- name: Check if version already on PyPI
|
|
41
|
+
id: check
|
|
42
|
+
run: |
|
|
43
|
+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://pypi.org/pypi/shipeasy/${{ steps.ver.outputs.version }}/json)
|
|
44
|
+
if [ "$STATUS" = "200" ]; then
|
|
45
|
+
echo "Already published: shipeasy==${{ steps.ver.outputs.version }}"
|
|
46
|
+
echo "skip=1" >> "$GITHUB_OUTPUT"
|
|
47
|
+
else
|
|
48
|
+
echo "skip=0" >> "$GITHUB_OUTPUT"
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
- name: Build distributions
|
|
52
|
+
if: steps.check.outputs.skip != '1'
|
|
53
|
+
run: python -m build
|
|
54
|
+
|
|
55
|
+
# Requires PyPI Trusted Publishing to be configured for the
|
|
56
|
+
# `shipeasy` project on pypi.org → repo `shipeasy-ai/sdk-python`,
|
|
57
|
+
# workflow `publish.yml`. Set vars.PUBLISH_ENABLED=true once
|
|
58
|
+
# trusted publisher is configured.
|
|
59
|
+
- name: Publish to PyPI (Trusted Publishing)
|
|
60
|
+
if: steps.check.outputs.skip != '1' && vars.PUBLISH_ENABLED == 'true'
|
|
61
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
62
|
+
|
|
63
|
+
- name: Publish skipped notice
|
|
64
|
+
if: steps.check.outputs.skip != '1' && vars.PUBLISH_ENABLED != 'true'
|
|
65
|
+
run: echo "::warning::vars.PUBLISH_ENABLED is not 'true' — configure PyPI Trusted Publishing on this repo, then set the variable."
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- **Anonymous bucketing (`__se_anon_id`).** Added `AnonIdMiddleware` (WSGI) and
|
|
6
|
+
`AnonIdASGIMiddleware` (ASGI) — zero-dependency middleware that mints the
|
|
7
|
+
shared `__se_anon_id` first-party cookie for any request without one and
|
|
8
|
+
exposes it on the request (`environ["shipeasy.anon_id"]`). Gate/experiment
|
|
9
|
+
evaluations now default to the cookie id as `anonymous_id` (via a `ContextVar`,
|
|
10
|
+
so it works under threads and asyncio), so anonymous visitors bucket
|
|
11
|
+
consistently across server renders and the browser with no per-call wiring.
|
|
12
|
+
Implements the cross-SDK contract in `18-identity-bucketing.md`.
|
|
13
|
+
- **Eval fix (no-unit gate rule).** A request with no `user_id`/`anonymous_id`
|
|
14
|
+
now resolves a fully-rolled (100%) gate as **on** instead of always off; a
|
|
15
|
+
fractional gate is still off until a stable unit exists. Matches the
|
|
16
|
+
TypeScript reference SDK. Targeting rules are still evaluated first.
|
|
17
|
+
|
|
18
|
+
## 0.2.0
|
|
19
|
+
|
|
20
|
+
- Per-evaluation usage telemetry (fire-and-forget, on by default).
|
|
21
|
+
|
|
22
|
+
## 0.1.0
|
|
23
|
+
|
|
24
|
+
- Initial release: feature flags, configs, experiments, metric tracking.
|
shipeasy-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Shipeasy Source-Available License (Shipeasy-SAL) 1.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shipeasy, Inc. All rights reserved.
|
|
4
|
+
|
|
5
|
+
1. License Grant.
|
|
6
|
+
Subject to the terms of this License, Shipeasy, Inc. ("Shipeasy") grants
|
|
7
|
+
you a non-exclusive, non-transferable, revocable, worldwide license to:
|
|
8
|
+
|
|
9
|
+
(a) Use, copy, and modify the Software solely as a client integration for
|
|
10
|
+
interacting with Shipeasy's hosted services (the "Service");
|
|
11
|
+
(b) Distribute the Software as part of an application that calls the
|
|
12
|
+
Service, in object form, provided the recipient also agrees to this
|
|
13
|
+
License.
|
|
14
|
+
|
|
15
|
+
2. Restrictions.
|
|
16
|
+
You may not:
|
|
17
|
+
|
|
18
|
+
(a) Use the Software, in whole or in part, to build, host, or operate any
|
|
19
|
+
service that competes with the Service or that provides feature-flag,
|
|
20
|
+
experimentation, configuration, internationalization, or related
|
|
21
|
+
functionality to third parties on a commercial basis;
|
|
22
|
+
(b) Sublicense, sell, rent, or lease the Software;
|
|
23
|
+
(c) Remove or alter copyright notices, license terms, or attribution.
|
|
24
|
+
|
|
25
|
+
3. Contributions.
|
|
26
|
+
Any pull request you submit is licensed back to Shipeasy under this
|
|
27
|
+
License plus a perpetual, irrevocable right for Shipeasy to relicense.
|
|
28
|
+
|
|
29
|
+
4. Trademarks.
|
|
30
|
+
This License does not grant rights in the names "Shipeasy", related
|
|
31
|
+
marks, or logos.
|
|
32
|
+
|
|
33
|
+
5. No Warranty / Limitation of Liability.
|
|
34
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. IN NO
|
|
35
|
+
EVENT SHALL SHIPEASY BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
|
36
|
+
LIABILITY ARISING FROM USE OF THE SOFTWARE.
|
|
37
|
+
|
|
38
|
+
6. Termination.
|
|
39
|
+
This License terminates automatically if you breach it. Sections 2-5
|
|
40
|
+
survive termination.
|
shipeasy-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shipeasy
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Shipeasy server SDK for Python — feature flags, configs, experiments, metrics.
|
|
5
|
+
Project-URL: Homepage, https://shipeasy.dev
|
|
6
|
+
Project-URL: Source, https://github.com/shipeasy-ai/sdk-python
|
|
7
|
+
Author: Shipeasy
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# shipeasy (Python)
|
|
17
|
+
|
|
18
|
+
Server SDK for [Shipeasy](https://shipeasy.dev) — feature flags, remote configs, A/B experiments, and metric tracking. Server-key only, never embed in browsers.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install shipeasy
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from shipeasy import Client
|
|
26
|
+
|
|
27
|
+
client = Client(api_key="sdk_server_...")
|
|
28
|
+
client.init() # background poll; use init_once() for serverless
|
|
29
|
+
|
|
30
|
+
if client.get_flag("new_checkout", {"user_id": "u_123", "country": "US"}):
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
config = client.get_config("billing_copy")
|
|
34
|
+
|
|
35
|
+
result = client.get_experiment(
|
|
36
|
+
"checkout_button",
|
|
37
|
+
user={"user_id": "u_123"},
|
|
38
|
+
default_params={"color": "blue"},
|
|
39
|
+
)
|
|
40
|
+
print(result.in_experiment, result.group, result.params)
|
|
41
|
+
|
|
42
|
+
client.track("u_123", "purchase", {"amount": 49})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Anonymous visitors (zero-config bucketing)
|
|
46
|
+
|
|
47
|
+
For logged-out traffic you need a *stable* unit so a fractional rollout buckets
|
|
48
|
+
the same on the server and in the browser. The middleware mints a first-party
|
|
49
|
+
`__se_anon_id` cookie (shared with every Shipeasy SDK) for any request without
|
|
50
|
+
one; evaluations then **default to it** as `anonymous_id`, so `get_flag` on an
|
|
51
|
+
anonymous request just works — no per-call wiring.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# WSGI (Flask, Django, ...)
|
|
55
|
+
from shipeasy.middleware import AnonIdMiddleware
|
|
56
|
+
app.wsgi_app = AnonIdMiddleware(app.wsgi_app)
|
|
57
|
+
|
|
58
|
+
# ASGI (FastAPI, Starlette)
|
|
59
|
+
from shipeasy.middleware import AnonIdASGIMiddleware
|
|
60
|
+
app.add_middleware(AnonIdASGIMiddleware)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# logged-out request → buckets on the __se_anon_id cookie automatically
|
|
65
|
+
client.get_flag("new_checkout", {})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
An explicit `user_id`/`anonymous_id` always wins. The id is also on the request
|
|
69
|
+
(`environ["shipeasy.anon_id"]`). The cookie is non-`HttpOnly` by design so the
|
|
70
|
+
browser SDK buckets identically; a request with **no** unit still resolves a
|
|
71
|
+
fully-rolled (100%) gate as on. Cookie name + format are a cross-SDK contract —
|
|
72
|
+
see `18-identity-bucketing.md`.
|
|
73
|
+
|
|
74
|
+
## Evaluation
|
|
75
|
+
|
|
76
|
+
Tested against the cross-language MurmurHash3 vectors in `experiment-platform/04-evaluation.md`.
|
shipeasy-0.3.0/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# shipeasy (Python)
|
|
2
|
+
|
|
3
|
+
Server SDK for [Shipeasy](https://shipeasy.dev) — feature flags, remote configs, A/B experiments, and metric tracking. Server-key only, never embed in browsers.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install shipeasy
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from shipeasy import Client
|
|
11
|
+
|
|
12
|
+
client = Client(api_key="sdk_server_...")
|
|
13
|
+
client.init() # background poll; use init_once() for serverless
|
|
14
|
+
|
|
15
|
+
if client.get_flag("new_checkout", {"user_id": "u_123", "country": "US"}):
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
config = client.get_config("billing_copy")
|
|
19
|
+
|
|
20
|
+
result = client.get_experiment(
|
|
21
|
+
"checkout_button",
|
|
22
|
+
user={"user_id": "u_123"},
|
|
23
|
+
default_params={"color": "blue"},
|
|
24
|
+
)
|
|
25
|
+
print(result.in_experiment, result.group, result.params)
|
|
26
|
+
|
|
27
|
+
client.track("u_123", "purchase", {"amount": 49})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Anonymous visitors (zero-config bucketing)
|
|
31
|
+
|
|
32
|
+
For logged-out traffic you need a *stable* unit so a fractional rollout buckets
|
|
33
|
+
the same on the server and in the browser. The middleware mints a first-party
|
|
34
|
+
`__se_anon_id` cookie (shared with every Shipeasy SDK) for any request without
|
|
35
|
+
one; evaluations then **default to it** as `anonymous_id`, so `get_flag` on an
|
|
36
|
+
anonymous request just works — no per-call wiring.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# WSGI (Flask, Django, ...)
|
|
40
|
+
from shipeasy.middleware import AnonIdMiddleware
|
|
41
|
+
app.wsgi_app = AnonIdMiddleware(app.wsgi_app)
|
|
42
|
+
|
|
43
|
+
# ASGI (FastAPI, Starlette)
|
|
44
|
+
from shipeasy.middleware import AnonIdASGIMiddleware
|
|
45
|
+
app.add_middleware(AnonIdASGIMiddleware)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# logged-out request → buckets on the __se_anon_id cookie automatically
|
|
50
|
+
client.get_flag("new_checkout", {})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
An explicit `user_id`/`anonymous_id` always wins. The id is also on the request
|
|
54
|
+
(`environ["shipeasy.anon_id"]`). The cookie is non-`HttpOnly` by design so the
|
|
55
|
+
browser SDK buckets identically; a request with **no** unit still resolves a
|
|
56
|
+
fully-rolled (100%) gate as on. Cookie name + format are a cross-SDK contract —
|
|
57
|
+
see `18-identity-bucketing.md`.
|
|
58
|
+
|
|
59
|
+
## Evaluation
|
|
60
|
+
|
|
61
|
+
Tested against the cross-language MurmurHash3 vectors in `experiment-platform/04-evaluation.md`.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shipeasy"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Shipeasy server SDK for Python — feature flags, configs, experiments, metrics."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Shipeasy" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://shipeasy.dev"
|
|
21
|
+
Source = "https://github.com/shipeasy-ai/sdk-python"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["shipeasy"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from ._client import Client, ExperimentResult
|
|
2
|
+
from ._hash import murmur3
|
|
3
|
+
from .middleware import AnonIdMiddleware, AnonIdASGIMiddleware
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Client",
|
|
7
|
+
"ExperimentResult",
|
|
8
|
+
"murmur3",
|
|
9
|
+
"AnonIdMiddleware",
|
|
10
|
+
"AnonIdASGIMiddleware",
|
|
11
|
+
]
|
|
12
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Anonymous bucketing identity — the cross-SDK ``__se_anon_id`` cookie.
|
|
2
|
+
|
|
3
|
+
Gates and experiments bucket a unit with ``murmur3(salt:unit)``. For a logged-out
|
|
4
|
+
visitor the unit is a stable anonymous id carried in a single first-party cookie
|
|
5
|
+
that EVERY Shipeasy SDK (server + browser) reads and writes, so a server render
|
|
6
|
+
and the browser bucket a fractional rollout identically. The cookie name and
|
|
7
|
+
format are frozen across every language; see
|
|
8
|
+
``experiment-platform/18-identity-bucketing.md``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import uuid
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
COOKIE = "__se_anon_id"
|
|
19
|
+
MAX_AGE = 31_536_000 # 1 year, in seconds
|
|
20
|
+
|
|
21
|
+
# The cookie value is client-controllable and feeds bucketing, so a tampered
|
|
22
|
+
# value is treated as absent and a fresh id is minted. UUIDs satisfy this.
|
|
23
|
+
_VALID_RX = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
24
|
+
|
|
25
|
+
# Per-request id resolved by the middleware. A ContextVar (not thread-local)
|
|
26
|
+
# so it works under both threaded WSGI servers and asyncio/ASGI.
|
|
27
|
+
_current: ContextVar[Optional[str]] = ContextVar("shipeasy_anon_id", default=None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mint() -> str:
|
|
31
|
+
"""A fresh opaque bucketing id (UUIDv4)."""
|
|
32
|
+
return str(uuid.uuid4())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_valid(value: Optional[str]) -> bool:
|
|
36
|
+
return isinstance(value, str) and _VALID_RX.match(value) is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def current() -> Optional[str]:
|
|
40
|
+
"""The anon id the middleware resolved for the current request, or None.
|
|
41
|
+
|
|
42
|
+
``Client.get_flag`` / ``get_experiment`` fall back to this as the default
|
|
43
|
+
``anonymous_id``, so evaluations need no per-call wiring.
|
|
44
|
+
"""
|
|
45
|
+
return _current.get()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_current(value: Optional[str]):
|
|
49
|
+
"""Bind the current-request anon id; returns the reset token."""
|
|
50
|
+
return _current.set(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reset_current(token) -> None:
|
|
54
|
+
_current.reset(token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_cookie_header(header: Optional[str]) -> dict:
|
|
58
|
+
out: dict = {}
|
|
59
|
+
if not header:
|
|
60
|
+
return out
|
|
61
|
+
for pair in header.split(";"):
|
|
62
|
+
pair = pair.strip()
|
|
63
|
+
if "=" in pair:
|
|
64
|
+
k, v = pair.split("=", 1)
|
|
65
|
+
if k and k not in out:
|
|
66
|
+
out[k] = v
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_or_mint(cookie_header: Optional[str]):
|
|
71
|
+
"""Return ``(id, minted)`` for a raw Cookie header value."""
|
|
72
|
+
raw = parse_cookie_header(cookie_header).get(COOKIE)
|
|
73
|
+
if is_valid(raw):
|
|
74
|
+
return raw, False
|
|
75
|
+
return mint(), True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_set_cookie(value: str, secure: bool) -> str:
|
|
79
|
+
"""Format the ``Set-Cookie`` header value per the cross-SDK contract.
|
|
80
|
+
|
|
81
|
+
Non-HttpOnly by design — the browser SDK reads it via ``document.cookie`` to
|
|
82
|
+
bucket identically to the server.
|
|
83
|
+
"""
|
|
84
|
+
parts = [
|
|
85
|
+
f"{COOKIE}={value}",
|
|
86
|
+
"Path=/",
|
|
87
|
+
f"Max-Age={MAX_AGE}",
|
|
88
|
+
"SameSite=Lax",
|
|
89
|
+
]
|
|
90
|
+
if secure:
|
|
91
|
+
parts.append("Secure")
|
|
92
|
+
return "; ".join(parts)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.request
|
|
8
|
+
import urllib.error
|
|
9
|
+
from typing import Any, Callable, Mapping, Optional, TypeVar
|
|
10
|
+
|
|
11
|
+
from ._eval import ExperimentResult, eval_experiment, eval_gate
|
|
12
|
+
from ._telemetry import Telemetry, DEFAULT_TELEMETRY_URL
|
|
13
|
+
from . import _anon_id
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
log = logging.getLogger("shipeasy")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _with_anon_id(user: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
20
|
+
"""Default ``anonymous_id`` to the request's ``__se_anon_id`` (set by the
|
|
21
|
+
middleware) when the caller passed no explicit unit. A caller-supplied
|
|
22
|
+
``user_id``/``anonymous_id`` always wins; with no middleware this is a no-op.
|
|
23
|
+
"""
|
|
24
|
+
if user.get("user_id") or user.get("anonymous_id"):
|
|
25
|
+
return user
|
|
26
|
+
anon = _anon_id.current()
|
|
27
|
+
if not anon:
|
|
28
|
+
return user
|
|
29
|
+
merged = dict(user)
|
|
30
|
+
merged["anonymous_id"] = anon
|
|
31
|
+
return merged
|
|
32
|
+
|
|
33
|
+
_DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
|
|
34
|
+
_DEFAULT_POLL_INTERVAL = 30
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Client:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: Optional[str] = None,
|
|
42
|
+
*,
|
|
43
|
+
env: str = "prod",
|
|
44
|
+
disable_telemetry: bool = False,
|
|
45
|
+
telemetry_url: Optional[str] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._api_key = api_key
|
|
48
|
+
self._base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
|
|
49
|
+
# Per-evaluation usage telemetry. ON by default; pass
|
|
50
|
+
# disable_telemetry=True to opt out. See _telemetry.py.
|
|
51
|
+
self._telemetry = Telemetry(
|
|
52
|
+
endpoint=telemetry_url or DEFAULT_TELEMETRY_URL,
|
|
53
|
+
sdk_key=api_key,
|
|
54
|
+
side="server",
|
|
55
|
+
env=env,
|
|
56
|
+
disabled=disable_telemetry,
|
|
57
|
+
)
|
|
58
|
+
self._flags_blob: Optional[dict] = None
|
|
59
|
+
self._exps_blob: Optional[dict] = None
|
|
60
|
+
self._flags_etag: Optional[str] = None
|
|
61
|
+
self._exps_etag: Optional[str] = None
|
|
62
|
+
self._poll_interval = _DEFAULT_POLL_INTERVAL
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
self._stop = threading.Event()
|
|
65
|
+
self._thread: Optional[threading.Thread] = None
|
|
66
|
+
self._initialized = False
|
|
67
|
+
|
|
68
|
+
def init(self) -> None:
|
|
69
|
+
self._fetch_all()
|
|
70
|
+
self._initialized = True
|
|
71
|
+
self._start_poll()
|
|
72
|
+
|
|
73
|
+
def init_once(self) -> None:
|
|
74
|
+
if self._initialized:
|
|
75
|
+
return
|
|
76
|
+
self._fetch_all()
|
|
77
|
+
self._initialized = True
|
|
78
|
+
|
|
79
|
+
def destroy(self) -> None:
|
|
80
|
+
self._stop.set()
|
|
81
|
+
if self._thread:
|
|
82
|
+
self._thread.join(timeout=1)
|
|
83
|
+
self._thread = None
|
|
84
|
+
|
|
85
|
+
def get_flag(self, name: str, user: Mapping[str, Any]) -> bool:
|
|
86
|
+
self._telemetry.emit("gate", name)
|
|
87
|
+
with self._lock:
|
|
88
|
+
gate = (self._flags_blob or {}).get("gates", {}).get(name)
|
|
89
|
+
if not gate:
|
|
90
|
+
return False
|
|
91
|
+
return eval_gate(gate, _with_anon_id(user))
|
|
92
|
+
|
|
93
|
+
def get_config(
|
|
94
|
+
self, name: str, decode: Optional[Callable[[Any], T]] = None
|
|
95
|
+
) -> Optional[T]:
|
|
96
|
+
self._telemetry.emit("config", name)
|
|
97
|
+
with self._lock:
|
|
98
|
+
entry = (self._flags_blob or {}).get("configs", {}).get(name)
|
|
99
|
+
if not entry:
|
|
100
|
+
return None
|
|
101
|
+
value = entry.get("value")
|
|
102
|
+
if decode is None:
|
|
103
|
+
return value
|
|
104
|
+
try:
|
|
105
|
+
return decode(value)
|
|
106
|
+
except Exception as e: # noqa: BLE001
|
|
107
|
+
log.warning("get_config(%s) decode failed: %s", name, e)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def get_experiment(
|
|
111
|
+
self,
|
|
112
|
+
name: str,
|
|
113
|
+
user: Mapping[str, Any],
|
|
114
|
+
default_params: T,
|
|
115
|
+
decode: Optional[Callable[[Any], T]] = None,
|
|
116
|
+
) -> ExperimentResult:
|
|
117
|
+
self._telemetry.emit("experiment", name)
|
|
118
|
+
with self._lock:
|
|
119
|
+
flags_blob = self._flags_blob
|
|
120
|
+
exps_blob = self._exps_blob
|
|
121
|
+
exp = (exps_blob or {}).get("experiments", {}).get(name)
|
|
122
|
+
result = eval_experiment(exp, flags_blob, exps_blob, _with_anon_id(user))
|
|
123
|
+
if result.params is None:
|
|
124
|
+
result.params = default_params
|
|
125
|
+
if result.in_experiment and decode is not None:
|
|
126
|
+
try:
|
|
127
|
+
result.params = decode(result.params)
|
|
128
|
+
except Exception as e: # noqa: BLE001
|
|
129
|
+
log.warning("get_experiment(%s) decode failed: %s", name, e)
|
|
130
|
+
return ExperimentResult(False, "control", default_params)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def track(self, user_id: str, event_name: str, properties: Optional[Mapping[str, Any]] = None) -> None:
|
|
134
|
+
body = {
|
|
135
|
+
"events": [{
|
|
136
|
+
"type": "metric",
|
|
137
|
+
"event_name": event_name,
|
|
138
|
+
"user_id": str(user_id),
|
|
139
|
+
"ts": int(time.time() * 1000),
|
|
140
|
+
**({"properties": dict(properties)} if properties else {}),
|
|
141
|
+
}]
|
|
142
|
+
}
|
|
143
|
+
data = json.dumps(body).encode("utf-8")
|
|
144
|
+
threading.Thread(
|
|
145
|
+
target=self._post_silent,
|
|
146
|
+
args=("/collect", data),
|
|
147
|
+
daemon=True,
|
|
148
|
+
).start()
|
|
149
|
+
|
|
150
|
+
def _post_silent(self, path: str, data: bytes) -> None:
|
|
151
|
+
try:
|
|
152
|
+
req = urllib.request.Request(
|
|
153
|
+
f"{self._base_url}{path}",
|
|
154
|
+
data=data,
|
|
155
|
+
headers={"X-SDK-Key": self._api_key, "Content-Type": "text/plain"},
|
|
156
|
+
method="POST",
|
|
157
|
+
)
|
|
158
|
+
urllib.request.urlopen(req, timeout=10).read()
|
|
159
|
+
except Exception as e: # noqa: BLE001
|
|
160
|
+
log.warning("track failed: %s", e)
|
|
161
|
+
|
|
162
|
+
def _start_poll(self) -> None:
|
|
163
|
+
def loop() -> None:
|
|
164
|
+
while not self._stop.wait(self._poll_interval):
|
|
165
|
+
try:
|
|
166
|
+
self._fetch_all()
|
|
167
|
+
except Exception as e: # noqa: BLE001
|
|
168
|
+
log.warning("background poll failed: %s", e)
|
|
169
|
+
self._thread = threading.Thread(target=loop, daemon=True)
|
|
170
|
+
self._thread.start()
|
|
171
|
+
|
|
172
|
+
def _fetch_all(self) -> None:
|
|
173
|
+
interval = self._fetch_flags()
|
|
174
|
+
self._fetch_exps()
|
|
175
|
+
if interval and interval != self._poll_interval:
|
|
176
|
+
self._poll_interval = interval
|
|
177
|
+
|
|
178
|
+
def _fetch_flags(self) -> Optional[int]:
|
|
179
|
+
status, headers, body = self._http_get("/sdk/flags", self._flags_etag)
|
|
180
|
+
interval_str = headers.get("X-Poll-Interval") or headers.get("x-poll-interval")
|
|
181
|
+
interval = int(interval_str) if interval_str else None
|
|
182
|
+
if status == 304:
|
|
183
|
+
return interval
|
|
184
|
+
if status != 200:
|
|
185
|
+
raise RuntimeError(f"GET /sdk/flags returned {status}")
|
|
186
|
+
with self._lock:
|
|
187
|
+
etag = headers.get("ETag") or headers.get("etag")
|
|
188
|
+
if etag:
|
|
189
|
+
self._flags_etag = etag
|
|
190
|
+
self._flags_blob = json.loads(body)
|
|
191
|
+
return interval
|
|
192
|
+
|
|
193
|
+
def _fetch_exps(self) -> None:
|
|
194
|
+
status, headers, body = self._http_get("/sdk/experiments", self._exps_etag)
|
|
195
|
+
if status == 304:
|
|
196
|
+
return
|
|
197
|
+
if status != 200:
|
|
198
|
+
raise RuntimeError(f"GET /sdk/experiments returned {status}")
|
|
199
|
+
with self._lock:
|
|
200
|
+
etag = headers.get("ETag") or headers.get("etag")
|
|
201
|
+
if etag:
|
|
202
|
+
self._exps_etag = etag
|
|
203
|
+
self._exps_blob = json.loads(body)
|
|
204
|
+
|
|
205
|
+
def _http_get(self, path: str, etag: Optional[str]) -> tuple[int, Mapping[str, str], bytes]:
|
|
206
|
+
headers = {"X-SDK-Key": self._api_key}
|
|
207
|
+
if etag:
|
|
208
|
+
headers["If-None-Match"] = etag
|
|
209
|
+
req = urllib.request.Request(f"{self._base_url}{path}", headers=headers, method="GET")
|
|
210
|
+
try:
|
|
211
|
+
resp = urllib.request.urlopen(req, timeout=10)
|
|
212
|
+
return resp.status, dict(resp.headers), resp.read()
|
|
213
|
+
except urllib.error.HTTPError as e:
|
|
214
|
+
return e.code, dict(e.headers or {}), e.read() if e.fp else b""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Mapping, Optional
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ._hash import murmur3
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ExperimentResult:
|
|
10
|
+
in_experiment: bool
|
|
11
|
+
group: str
|
|
12
|
+
params: Optional[Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _enabled(v: Any) -> bool:
|
|
16
|
+
return v == 1 or v is True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_num(v: Any) -> Optional[float]:
|
|
20
|
+
if isinstance(v, bool):
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(v, (int, float)):
|
|
23
|
+
return float(v)
|
|
24
|
+
if isinstance(v, str):
|
|
25
|
+
try:
|
|
26
|
+
return float(v)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _user_id(user: Mapping[str, Any]) -> Optional[str]:
|
|
33
|
+
uid = user.get("user_id") or user.get("anonymous_id")
|
|
34
|
+
return str(uid) if uid else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def match_rule(rule: Mapping[str, Any], user: Mapping[str, Any]) -> bool:
|
|
38
|
+
attr = rule.get("attr")
|
|
39
|
+
op = rule.get("op")
|
|
40
|
+
value = rule.get("value")
|
|
41
|
+
actual = user.get(attr) if attr else None
|
|
42
|
+
|
|
43
|
+
if op == "eq":
|
|
44
|
+
return actual == value
|
|
45
|
+
if op == "neq":
|
|
46
|
+
return actual != value
|
|
47
|
+
if op == "in":
|
|
48
|
+
return actual in (value or [])
|
|
49
|
+
if op == "not_in":
|
|
50
|
+
return actual not in (value or [])
|
|
51
|
+
if op == "contains":
|
|
52
|
+
if isinstance(actual, str) and isinstance(value, str):
|
|
53
|
+
return value in actual
|
|
54
|
+
if isinstance(actual, list):
|
|
55
|
+
return value in actual
|
|
56
|
+
return False
|
|
57
|
+
if op == "regex":
|
|
58
|
+
if isinstance(actual, str) and isinstance(value, str):
|
|
59
|
+
try:
|
|
60
|
+
return re.search(value, actual) is not None
|
|
61
|
+
except re.error:
|
|
62
|
+
return False
|
|
63
|
+
return False
|
|
64
|
+
if op in ("gt", "gte", "lt", "lte"):
|
|
65
|
+
a = _to_num(actual)
|
|
66
|
+
b = _to_num(value)
|
|
67
|
+
if a is None or b is None:
|
|
68
|
+
return False
|
|
69
|
+
if op == "gt":
|
|
70
|
+
return a > b
|
|
71
|
+
if op == "gte":
|
|
72
|
+
return a >= b
|
|
73
|
+
if op == "lt":
|
|
74
|
+
return a < b
|
|
75
|
+
return a <= b
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def eval_gate(gate: Mapping[str, Any], user: Mapping[str, Any]) -> bool:
|
|
80
|
+
if _enabled(gate.get("killswitch")):
|
|
81
|
+
return False
|
|
82
|
+
if not _enabled(gate.get("enabled")):
|
|
83
|
+
return False
|
|
84
|
+
for rule in gate.get("rules") or []:
|
|
85
|
+
if not match_rule(rule, user):
|
|
86
|
+
return False
|
|
87
|
+
uid = _user_id(user)
|
|
88
|
+
if not uid:
|
|
89
|
+
# No unit id (an unidentified request before any anon id is minted): a
|
|
90
|
+
# fully-rolled gate is on for everyone, so it can be answered without
|
|
91
|
+
# bucketing; a fractional rollout genuinely needs a stable unit, so deny
|
|
92
|
+
# until one exists. Rules above are still checked, so targeting wins.
|
|
93
|
+
# See experiment-platform/18-identity-bucketing.md.
|
|
94
|
+
return (gate.get("rolloutPct") or 0) >= 10000
|
|
95
|
+
salt = gate.get("salt") or ""
|
|
96
|
+
return murmur3(f"{salt}:{uid}") % 10000 < (gate.get("rolloutPct") or 0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_NOT_IN = ExperimentResult(in_experiment=False, group="control", params=None)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def eval_experiment(
|
|
103
|
+
exp: Optional[Mapping[str, Any]],
|
|
104
|
+
flags_blob: Optional[Mapping[str, Any]],
|
|
105
|
+
exps_blob: Optional[Mapping[str, Any]],
|
|
106
|
+
user: Mapping[str, Any],
|
|
107
|
+
) -> ExperimentResult:
|
|
108
|
+
if not exp or exp.get("status") != "running":
|
|
109
|
+
return _NOT_IN
|
|
110
|
+
|
|
111
|
+
targeting_gate = exp.get("targetingGate")
|
|
112
|
+
if targeting_gate:
|
|
113
|
+
gate = (flags_blob or {}).get("gates", {}).get(targeting_gate)
|
|
114
|
+
if not gate or not eval_gate(gate, user):
|
|
115
|
+
return _NOT_IN
|
|
116
|
+
|
|
117
|
+
uid = _user_id(user)
|
|
118
|
+
if not uid:
|
|
119
|
+
return _NOT_IN
|
|
120
|
+
|
|
121
|
+
universe_name = exp.get("universe")
|
|
122
|
+
universe = (exps_blob or {}).get("universes", {}).get(universe_name) if universe_name else None
|
|
123
|
+
holdout = universe.get("holdout_range") if universe else None
|
|
124
|
+
if holdout:
|
|
125
|
+
seg = murmur3(f"{universe_name}:{uid}") % 10000
|
|
126
|
+
if holdout[0] <= seg <= holdout[1]:
|
|
127
|
+
return _NOT_IN
|
|
128
|
+
|
|
129
|
+
salt = exp.get("salt") or ""
|
|
130
|
+
alloc_pct = exp.get("allocationPct") or 0
|
|
131
|
+
if murmur3(f"{salt}:alloc:{uid}") % 10000 >= alloc_pct:
|
|
132
|
+
return _NOT_IN
|
|
133
|
+
|
|
134
|
+
group_hash = murmur3(f"{salt}:group:{uid}") % 10000
|
|
135
|
+
cumulative = 0
|
|
136
|
+
groups = exp.get("groups") or []
|
|
137
|
+
for i, g in enumerate(groups):
|
|
138
|
+
cumulative += g.get("weight", 0)
|
|
139
|
+
if group_hash < cumulative or i == len(groups) - 1:
|
|
140
|
+
return ExperimentResult(in_experiment=True, group=g.get("name", "control"), params=g.get("params"))
|
|
141
|
+
|
|
142
|
+
return _NOT_IN
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
_MASK32 = 0xFFFFFFFF
|
|
2
|
+
_C1 = 0xCC9E2D51
|
|
3
|
+
_C2 = 0x1B873593
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _rotl(x: int, r: int) -> int:
|
|
7
|
+
return ((x << r) | (x >> (32 - r))) & _MASK32
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _fmix32(h: int) -> int:
|
|
11
|
+
h ^= h >> 16
|
|
12
|
+
h = (h * 0x85EBCA6B) & _MASK32
|
|
13
|
+
h ^= h >> 13
|
|
14
|
+
h = (h * 0xC2B2AE35) & _MASK32
|
|
15
|
+
h ^= h >> 16
|
|
16
|
+
return h
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def murmur3(key: str, seed: int = 0) -> int:
|
|
20
|
+
data = key.encode("utf-8")
|
|
21
|
+
n = len(data)
|
|
22
|
+
h1 = seed & _MASK32
|
|
23
|
+
nblocks = n // 4
|
|
24
|
+
for i in range(nblocks):
|
|
25
|
+
off = i * 4
|
|
26
|
+
k1 = data[off] | (data[off + 1] << 8) | (data[off + 2] << 16) | (data[off + 3] << 24)
|
|
27
|
+
k1 = (k1 * _C1) & _MASK32
|
|
28
|
+
k1 = _rotl(k1, 15)
|
|
29
|
+
k1 = (k1 * _C2) & _MASK32
|
|
30
|
+
h1 ^= k1
|
|
31
|
+
h1 = _rotl(h1, 13)
|
|
32
|
+
h1 = ((h1 * 5) + 0xE6546B64) & _MASK32
|
|
33
|
+
|
|
34
|
+
tail_idx = nblocks * 4
|
|
35
|
+
k1 = 0
|
|
36
|
+
rem = n & 3
|
|
37
|
+
if rem >= 3:
|
|
38
|
+
k1 ^= data[tail_idx + 2] << 16
|
|
39
|
+
if rem >= 2:
|
|
40
|
+
k1 ^= data[tail_idx + 1] << 8
|
|
41
|
+
if rem >= 1:
|
|
42
|
+
k1 ^= data[tail_idx]
|
|
43
|
+
k1 = (k1 * _C1) & _MASK32
|
|
44
|
+
k1 = _rotl(k1, 15)
|
|
45
|
+
k1 = (k1 * _C2) & _MASK32
|
|
46
|
+
h1 ^= k1
|
|
47
|
+
|
|
48
|
+
h1 ^= n
|
|
49
|
+
return _fmix32(h1)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Per-evaluation usage telemetry.
|
|
2
|
+
|
|
3
|
+
Fires one fire-and-forget HTTP beacon per evaluation so usage is counted by
|
|
4
|
+
Cloudflare's native per-path analytics (zero storage on our side). Mirrors the
|
|
5
|
+
contract in the TypeScript reference SDK and experiment-platform/15-usage-metering.md.
|
|
6
|
+
|
|
7
|
+
The path carries sha256(sdk_key) -- never the raw key, so a secret server key
|
|
8
|
+
never lands in edge logs -- plus side/env, then feature/resource. A long-lived
|
|
9
|
+
Python process can emit reliably (unlike Cloudflare Workers), so a daemon thread
|
|
10
|
+
per beacon is fine; the 2s dedup window bounds volume under render/loop storms.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
from typing import Dict
|
|
20
|
+
|
|
21
|
+
DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai"
|
|
22
|
+
_FEATURES = frozenset({"gate", "config", "ks", "experiment", "event"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Telemetry:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
endpoint: str,
|
|
29
|
+
sdk_key: str,
|
|
30
|
+
side: str = "server",
|
|
31
|
+
env: str = "prod",
|
|
32
|
+
disabled: bool = False,
|
|
33
|
+
dedupe_ms: int = 2000,
|
|
34
|
+
) -> None:
|
|
35
|
+
endpoint = (endpoint or "").rstrip("/")
|
|
36
|
+
self._disabled = disabled or not sdk_key or not endpoint
|
|
37
|
+
self._dedupe_ms = dedupe_ms
|
|
38
|
+
self._last: Dict[str, float] = {}
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
if self._disabled:
|
|
41
|
+
self._prefix = ""
|
|
42
|
+
else:
|
|
43
|
+
key_hash = hashlib.sha256(sdk_key.encode("utf-8")).hexdigest()
|
|
44
|
+
self._prefix = f"{endpoint}/t/{key_hash}/{side}/{quote(env, safe='')}"
|
|
45
|
+
|
|
46
|
+
def emit(self, feature: str, resource: str) -> None:
|
|
47
|
+
"""Best-effort usage beacon for one evaluation. Never blocks, never raises."""
|
|
48
|
+
if self._disabled:
|
|
49
|
+
return
|
|
50
|
+
if self._dedupe_ms > 0:
|
|
51
|
+
dedupe_key = f"{feature}/{resource}"
|
|
52
|
+
now = time.monotonic() * 1000.0
|
|
53
|
+
with self._lock:
|
|
54
|
+
last = self._last.get(dedupe_key)
|
|
55
|
+
if last is not None and now - last < self._dedupe_ms:
|
|
56
|
+
return
|
|
57
|
+
self._last[dedupe_key] = now
|
|
58
|
+
url = f"{self._prefix}/{feature}/{quote(resource, safe='')}"
|
|
59
|
+
threading.Thread(target=_send, args=(url,), daemon=True).start()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _send(url: str) -> None:
|
|
63
|
+
try:
|
|
64
|
+
req = urllib.request.Request(url, method="GET")
|
|
65
|
+
urllib.request.urlopen(req, timeout=2).close()
|
|
66
|
+
except Exception: # noqa: BLE001 -- telemetry must never affect the caller
|
|
67
|
+
pass
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Drop-in WSGI / ASGI middleware that mints the shared ``__se_anon_id`` cookie.
|
|
2
|
+
|
|
3
|
+
For any request without a valid ``__se_anon_id`` cookie it mints a UUIDv4,
|
|
4
|
+
exposes it for the duration of the request, and ``Set-Cookie``s it on the
|
|
5
|
+
response. Once installed, gate/experiment evaluations with no explicit
|
|
6
|
+
``user_id``/``anonymous_id`` automatically bucket on the cookie id — anonymous
|
|
7
|
+
visitors get stable, SSR/browser-consistent bucketing with zero per-call wiring.
|
|
8
|
+
|
|
9
|
+
WSGI (Flask, Django, any WSGI app)::
|
|
10
|
+
|
|
11
|
+
from shipeasy.middleware import AnonIdMiddleware
|
|
12
|
+
app.wsgi_app = AnonIdMiddleware(app.wsgi_app)
|
|
13
|
+
|
|
14
|
+
ASGI (FastAPI, Starlette)::
|
|
15
|
+
|
|
16
|
+
from shipeasy.middleware import AnonIdASGIMiddleware
|
|
17
|
+
app.add_middleware(AnonIdASGIMiddleware)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from . import _anon_id as anon_id
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnonIdMiddleware:
|
|
28
|
+
"""WSGI middleware."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, app: Callable) -> None:
|
|
31
|
+
self.app = app
|
|
32
|
+
|
|
33
|
+
def __call__(self, environ, start_response):
|
|
34
|
+
anon, minted = anon_id.read_or_mint(environ.get("HTTP_COOKIE"))
|
|
35
|
+
environ["shipeasy.anon_id"] = anon
|
|
36
|
+
token = anon_id.set_current(anon)
|
|
37
|
+
|
|
38
|
+
def _start_response(status, headers, exc_info=None):
|
|
39
|
+
if minted:
|
|
40
|
+
secure = environ.get("wsgi.url_scheme") == "https" or (
|
|
41
|
+
environ.get("HTTP_X_FORWARDED_PROTO", "").split(",")[0].strip() == "https"
|
|
42
|
+
)
|
|
43
|
+
headers = list(headers) + [("Set-Cookie", anon_id.build_set_cookie(anon, secure))]
|
|
44
|
+
return start_response(status, headers, exc_info)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
return self.app(environ, _start_response)
|
|
48
|
+
finally:
|
|
49
|
+
anon_id.reset_current(token)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AnonIdASGIMiddleware:
|
|
53
|
+
"""Pure-ASGI middleware (HTTP scope only; other scopes pass through)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, app) -> None:
|
|
56
|
+
self.app = app
|
|
57
|
+
|
|
58
|
+
async def __call__(self, scope, receive, send):
|
|
59
|
+
if scope.get("type") != "http":
|
|
60
|
+
await self.app(scope, receive, send)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
header = b"".join(
|
|
64
|
+
v for k, v in scope.get("headers", []) if k == b"cookie"
|
|
65
|
+
).decode("latin-1") or None
|
|
66
|
+
anon, minted = anon_id.read_or_mint(header)
|
|
67
|
+
token = anon_id.set_current(anon)
|
|
68
|
+
|
|
69
|
+
async def _send(message):
|
|
70
|
+
if minted and message["type"] == "http.response.start":
|
|
71
|
+
secure = scope.get("scheme") == "https" or _xfp_https(scope)
|
|
72
|
+
cookie = anon_id.build_set_cookie(anon, secure).encode("latin-1")
|
|
73
|
+
message = dict(message)
|
|
74
|
+
message["headers"] = list(message.get("headers", [])) + [(b"set-cookie", cookie)]
|
|
75
|
+
await send(message)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
await self.app(scope, receive, _send)
|
|
79
|
+
finally:
|
|
80
|
+
anon_id.reset_current(token)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _xfp_https(scope) -> bool:
|
|
84
|
+
for k, v in scope.get("headers", []):
|
|
85
|
+
if k == b"x-forwarded-proto":
|
|
86
|
+
return v.decode("latin-1").split(",")[0].strip() == "https"
|
|
87
|
+
return False
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from shipeasy._eval import eval_gate
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# The no-unit evaluation rule is a cross-SDK contract: a request with no unit id
|
|
5
|
+
# answers a fully-rolled gate as on (no bucketing needed) but a fractional gate
|
|
6
|
+
# as off. See experiment-platform/18-identity-bucketing.md.
|
|
7
|
+
def test_no_unit_full_rollout_is_on():
|
|
8
|
+
assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 10000}, {}) is True
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_no_unit_fractional_is_off():
|
|
12
|
+
assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 5000}, {}) is False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_no_unit_disabled_or_killed_is_off():
|
|
16
|
+
assert eval_gate({"enabled": 0, "rolloutPct": 10000}, {}) is False
|
|
17
|
+
assert eval_gate({"enabled": 1, "killswitch": 1, "rolloutPct": 10000}, {}) is False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_no_unit_targeting_rule_wins():
|
|
21
|
+
gate = {
|
|
22
|
+
"enabled": 1, "salt": "s", "rolloutPct": 10000,
|
|
23
|
+
"rules": [{"attr": "plan", "op": "eq", "value": "pro"}],
|
|
24
|
+
}
|
|
25
|
+
assert eval_gate(gate, {}) is False
|
|
26
|
+
assert eval_gate(gate, {"plan": "pro"}) is True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_with_unit_unchanged():
|
|
30
|
+
assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 0}, {"user_id": "u1"}) is False
|
|
31
|
+
assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 10000}, {"user_id": "u1"}) is True
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from shipeasy import murmur3
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_vectors():
|
|
5
|
+
# Verified against the Ruby SDK reference impl. Cross-language
|
|
6
|
+
# consistency is the contract; the table in
|
|
7
|
+
# experiment-platform/04-evaluation.md disagrees on some inputs and
|
|
8
|
+
# appears to be unverified.
|
|
9
|
+
cases = [
|
|
10
|
+
("", 0x00000000),
|
|
11
|
+
("a", 0x3c2569b2),
|
|
12
|
+
("ab", 0x9bbfd75f),
|
|
13
|
+
("abc", 0xb3dd93fa),
|
|
14
|
+
("aaaa", 0x7eeed987),
|
|
15
|
+
("aaaaa", 0xe9ca302b),
|
|
16
|
+
("Hello, 世界", 0xe2a131eb),
|
|
17
|
+
("The quick brown fox jumps over the lazy dog", 0x2e4ff723),
|
|
18
|
+
]
|
|
19
|
+
for inp, expected in cases:
|
|
20
|
+
assert murmur3(inp) == expected, inp
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from shipeasy import _anon_id
|
|
4
|
+
from shipeasy.middleware import AnonIdMiddleware, AnonIdASGIMiddleware
|
|
5
|
+
from shipeasy._client import _with_anon_id
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _run_wsgi(environ, downstream):
|
|
9
|
+
captured = {}
|
|
10
|
+
|
|
11
|
+
def start_response(status, headers, exc_info=None):
|
|
12
|
+
captured["status"] = status
|
|
13
|
+
captured["headers"] = headers
|
|
14
|
+
|
|
15
|
+
app = AnonIdMiddleware(lambda env, sr: (downstream(env), sr("200 OK", []), [b"ok"])[-1])
|
|
16
|
+
list(app(environ, start_response))
|
|
17
|
+
return captured
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _set_cookie(headers):
|
|
21
|
+
return [v for k, v in headers if k.lower() == "set-cookie"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_wsgi_mints_and_sets_cookie():
|
|
25
|
+
seen = {}
|
|
26
|
+
cap = _run_wsgi(
|
|
27
|
+
{"wsgi.url_scheme": "https"},
|
|
28
|
+
lambda env: seen.update(id=env["shipeasy.anon_id"], cur=_anon_id.current()),
|
|
29
|
+
)
|
|
30
|
+
assert _anon_id.is_valid(seen["id"])
|
|
31
|
+
assert seen["cur"] == seen["id"]
|
|
32
|
+
cookies = _set_cookie(cap["headers"])
|
|
33
|
+
assert len(cookies) == 1
|
|
34
|
+
c = cookies[0]
|
|
35
|
+
assert f"{_anon_id.COOKIE}={seen['id']}" in c
|
|
36
|
+
assert "Path=/" in c and "Max-Age=31536000" in c and "SameSite=Lax" in c and "Secure" in c
|
|
37
|
+
assert "HttpOnly" not in c
|
|
38
|
+
# ContextVar cleared after the request.
|
|
39
|
+
assert _anon_id.current() is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_wsgi_reuses_existing_cookie():
|
|
43
|
+
seen = {}
|
|
44
|
+
cap = _run_wsgi(
|
|
45
|
+
{"HTTP_COOKIE": f"{_anon_id.COOKIE}=stable-1; other=x"},
|
|
46
|
+
lambda env: seen.update(id=env["shipeasy.anon_id"]),
|
|
47
|
+
)
|
|
48
|
+
assert seen["id"] == "stable-1"
|
|
49
|
+
assert _set_cookie(cap["headers"]) == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_wsgi_mints_on_tampered_cookie():
|
|
53
|
+
seen = {}
|
|
54
|
+
_run_wsgi(
|
|
55
|
+
{"HTTP_COOKIE": f"{_anon_id.COOKIE}=bad value!"},
|
|
56
|
+
lambda env: seen.update(id=env["shipeasy.anon_id"]),
|
|
57
|
+
)
|
|
58
|
+
assert seen["id"] != "bad value!"
|
|
59
|
+
assert _anon_id.is_valid(seen["id"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_with_anon_id_defaulting():
|
|
63
|
+
token = _anon_id.set_current("anon-xyz")
|
|
64
|
+
try:
|
|
65
|
+
assert _with_anon_id({})["anonymous_id"] == "anon-xyz"
|
|
66
|
+
assert "anonymous_id" not in _with_anon_id({"user_id": "u9"})
|
|
67
|
+
assert _with_anon_id({"anonymous_id": "caller"})["anonymous_id"] == "caller"
|
|
68
|
+
finally:
|
|
69
|
+
_anon_id.reset_current(token)
|
|
70
|
+
assert _anon_id.current() is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_asgi_mints_and_sets_cookie():
|
|
74
|
+
sent = []
|
|
75
|
+
seen = {}
|
|
76
|
+
|
|
77
|
+
async def downstream(scope, receive, send):
|
|
78
|
+
seen["cur"] = _anon_id.current()
|
|
79
|
+
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
80
|
+
await send({"type": "http.response.body", "body": b"ok"})
|
|
81
|
+
|
|
82
|
+
async def receive():
|
|
83
|
+
return {"type": "http.request"}
|
|
84
|
+
|
|
85
|
+
async def send(m):
|
|
86
|
+
sent.append(m)
|
|
87
|
+
|
|
88
|
+
app = AnonIdASGIMiddleware(downstream)
|
|
89
|
+
asyncio.run(app({"type": "http", "scheme": "https", "headers": []}, receive, send))
|
|
90
|
+
|
|
91
|
+
assert _anon_id.is_valid(seen["cur"])
|
|
92
|
+
start = next(m for m in sent if m["type"] == "http.response.start")
|
|
93
|
+
cookies = [v.decode() for k, v in start["headers"] if k == b"set-cookie"]
|
|
94
|
+
assert len(cookies) == 1 and "SameSite=Lax" in cookies[0] and "Secure" in cookies[0]
|
|
95
|
+
assert _anon_id.current() is None
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from shipeasy import _telemetry
|
|
4
|
+
from shipeasy._telemetry import Telemetry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _capture(monkeypatch):
|
|
8
|
+
sent = []
|
|
9
|
+
# Run synchronously and record, instead of spawning daemon threads.
|
|
10
|
+
monkeypatch.setattr(_telemetry, "_send", lambda url: sent.append(url))
|
|
11
|
+
monkeypatch.setattr(
|
|
12
|
+
_telemetry.threading,
|
|
13
|
+
"Thread",
|
|
14
|
+
lambda target, args, daemon: type("T", (), {"start": lambda self: target(*args)})(),
|
|
15
|
+
)
|
|
16
|
+
return sent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_emit_path_has_hash_not_raw_key(monkeypatch):
|
|
20
|
+
sent = _capture(monkeypatch)
|
|
21
|
+
t = Telemetry("https://t.example.com/", "sk_secret", side="server", env="prod")
|
|
22
|
+
t.emit("gate", "checkout_v2")
|
|
23
|
+
h = hashlib.sha256(b"sk_secret").hexdigest()
|
|
24
|
+
assert sent == [f"https://t.example.com/t/{h}/server/prod/gate/checkout_v2"]
|
|
25
|
+
assert "sk_secret" not in sent[0]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_percent_encodes_resource(monkeypatch):
|
|
29
|
+
sent = _capture(monkeypatch)
|
|
30
|
+
t = Telemetry("https://e.x", "k", side="client", env="prod")
|
|
31
|
+
t.emit("config", "billing/plan name")
|
|
32
|
+
assert sent[0].endswith("/config/billing%2Fplan%20name")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_dedup_window_collapses_repeats(monkeypatch):
|
|
36
|
+
sent = _capture(monkeypatch)
|
|
37
|
+
t = Telemetry("https://e.x", "k")
|
|
38
|
+
for _ in range(50):
|
|
39
|
+
t.emit("gate", "g")
|
|
40
|
+
t.emit("gate", "other")
|
|
41
|
+
assert len(sent) == 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_disabled_and_empty_emit_nothing(monkeypatch):
|
|
45
|
+
sent = _capture(monkeypatch)
|
|
46
|
+
Telemetry("https://e.x", "k", disabled=True).emit("gate", "g")
|
|
47
|
+
Telemetry("https://e.x", "").emit("gate", "g")
|
|
48
|
+
Telemetry("", "k").emit("gate", "g")
|
|
49
|
+
assert sent == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# 1) basic telemetry send works for each entity call, hitting the right URL.
|
|
53
|
+
def test_client_fires_a_beacon_for_each_entity(monkeypatch):
|
|
54
|
+
sent = _capture(monkeypatch)
|
|
55
|
+
from shipeasy import Client
|
|
56
|
+
|
|
57
|
+
c = Client("srv_key", base_url="https://e.x")
|
|
58
|
+
c.get_flag("g", {"user_id": "u"})
|
|
59
|
+
c.get_config("c")
|
|
60
|
+
c.get_experiment("e", {"user_id": "u"}, {})
|
|
61
|
+
assert len(sent) == 3
|
|
62
|
+
assert any(u.endswith("/gate/g") for u in sent)
|
|
63
|
+
assert any(u.endswith("/config/c") for u in sent)
|
|
64
|
+
assert any(u.endswith("/experiment/e") for u in sent)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# 2) telemetry is not sent when disabled in settings.
|
|
68
|
+
def test_client_disable_telemetry_sends_nothing(monkeypatch):
|
|
69
|
+
sent = _capture(monkeypatch)
|
|
70
|
+
from shipeasy import Client
|
|
71
|
+
|
|
72
|
+
c = Client("srv_key", base_url="https://e.x", disable_telemetry=True)
|
|
73
|
+
c.get_flag("g", {"user_id": "u"})
|
|
74
|
+
c.get_config("c")
|
|
75
|
+
c.get_experiment("e", {"user_id": "u"}, {})
|
|
76
|
+
assert sent == []
|