gamepulse-sdk 0.1.0__py3-none-any.whl
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.
- gamepulse/__init__.py +83 -0
- gamepulse/client.py +365 -0
- gamepulse/config.py +42 -0
- gamepulse/context.py +31 -0
- gamepulse/crash.py +52 -0
- gamepulse/events/__init__.py +3 -0
- gamepulse/events/custom.py +9 -0
- gamepulse/events/economy.py +23 -0
- gamepulse/events/gameplay.py +17 -0
- gamepulse/events/progression.py +17 -0
- gamepulse/py.typed +0 -0
- gamepulse/queue.py +68 -0
- gamepulse/session.py +18 -0
- gamepulse/storage.py +206 -0
- gamepulse/transport.py +61 -0
- gamepulse/utils/__init__.py +0 -0
- gamepulse/utils/ids.py +5 -0
- gamepulse/utils/logging.py +5 -0
- gamepulse_sdk-0.1.0.dist-info/METADATA +749 -0
- gamepulse_sdk-0.1.0.dist-info/RECORD +22 -0
- gamepulse_sdk-0.1.0.dist-info/WHEEL +4 -0
- gamepulse_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
gamepulse/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""GamePulse Python SDK — public API surface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from gamepulse.client import GamePulseClient, get_client
|
|
8
|
+
from gamepulse.config import SDKConfig
|
|
9
|
+
from gamepulse.events import economy, gameplay, progression
|
|
10
|
+
from gamepulse.session import Session
|
|
11
|
+
from gamepulse_core.version import __version__
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"GamePulseClient",
|
|
15
|
+
"SDKConfig",
|
|
16
|
+
"Session",
|
|
17
|
+
"__version__",
|
|
18
|
+
"economy",
|
|
19
|
+
"flush",
|
|
20
|
+
"gameplay",
|
|
21
|
+
"identify",
|
|
22
|
+
"init",
|
|
23
|
+
"progression",
|
|
24
|
+
"session",
|
|
25
|
+
"shutdown",
|
|
26
|
+
"track",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def init(
|
|
31
|
+
api_key: str | None,
|
|
32
|
+
project: str | None = None,
|
|
33
|
+
player_id: str | None = None,
|
|
34
|
+
api_url: str | None = None,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> GamePulseClient:
|
|
37
|
+
"""Initialize the global SDK client.
|
|
38
|
+
|
|
39
|
+
When *api_key* is ``None`` the SDK runs in no-op mode (safe for tests).
|
|
40
|
+
When *api_key* is set, *api_url* must also be provided.
|
|
41
|
+
"""
|
|
42
|
+
if api_key and not api_url:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"api_url is required when api_key is set. "
|
|
45
|
+
"Pass the URL of your GamePulse API, e.g. api_url='https://your-server.com'."
|
|
46
|
+
)
|
|
47
|
+
cfg = SDKConfig(
|
|
48
|
+
api_key=api_key,
|
|
49
|
+
project=project,
|
|
50
|
+
player_id=player_id,
|
|
51
|
+
api_url=api_url or "",
|
|
52
|
+
**kwargs,
|
|
53
|
+
)
|
|
54
|
+
return GamePulseClient.initialize(cfg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def track(event_name: str, **payload: Any) -> None:
|
|
58
|
+
get_client().track(event_name, **payload)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def identify(player_id: str, **attributes: Any) -> None:
|
|
62
|
+
get_client().identify(player_id, **attributes)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def flush(timeout_s: float | None = None) -> None:
|
|
66
|
+
get_client().flush(timeout_s)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def shutdown() -> None:
|
|
70
|
+
get_client().shutdown()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@contextmanager
|
|
74
|
+
def session(**kwargs: Any):
|
|
75
|
+
client = get_client()
|
|
76
|
+
sess = client.start_session(**kwargs)
|
|
77
|
+
try:
|
|
78
|
+
yield sess
|
|
79
|
+
except BaseException:
|
|
80
|
+
client.end_session(end_reason="crash")
|
|
81
|
+
raise
|
|
82
|
+
else:
|
|
83
|
+
client.end_session(end_reason="normal")
|
gamepulse/client.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import threading
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from gamepulse_core import (
|
|
11
|
+
BaseEvent,
|
|
12
|
+
BatchEventsRequest,
|
|
13
|
+
CrashIngestRequest,
|
|
14
|
+
EventCategory,
|
|
15
|
+
SessionEndReason,
|
|
16
|
+
SessionEndRequest,
|
|
17
|
+
SessionStartRequest,
|
|
18
|
+
)
|
|
19
|
+
from gamepulse_core.version import __version__ as CORE_VERSION
|
|
20
|
+
|
|
21
|
+
from gamepulse.config import SDKConfig
|
|
22
|
+
from gamepulse.context import build_device_context
|
|
23
|
+
from gamepulse.queue import EventQueue
|
|
24
|
+
from gamepulse.session import Session
|
|
25
|
+
from gamepulse.storage import OfflineStore
|
|
26
|
+
from gamepulse.transport import Transport
|
|
27
|
+
from gamepulse.utils.logging import log
|
|
28
|
+
|
|
29
|
+
# Send outcomes used by the offline-persistence paths.
|
|
30
|
+
_OK = "ok" # 2xx — accepted by the backend
|
|
31
|
+
_RETRY = "retry" # transient (network / 5xx / offline) — persist and retry later
|
|
32
|
+
_DROP = "drop" # permanent (4xx) — give up, never retry
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _chunks(seq: list, size: int):
|
|
36
|
+
size = max(size, 1)
|
|
37
|
+
for i in range(0, len(seq), size):
|
|
38
|
+
yield seq[i : i + size]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fire_error(cfg: SDKConfig, description: str, count: int) -> None:
|
|
42
|
+
"""Call the user's on_send_error callback if one is registered. Never raises."""
|
|
43
|
+
if cfg.on_send_error is None:
|
|
44
|
+
return
|
|
45
|
+
try:
|
|
46
|
+
cfg.on_send_error(description, count)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
log.warning("gamepulse: on_send_error callback raised: %s", exc)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GamePulseClient:
|
|
52
|
+
_instance: GamePulseClient | None = None
|
|
53
|
+
_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
def __init__(self, cfg: SDKConfig) -> None:
|
|
56
|
+
self.cfg = cfg
|
|
57
|
+
self.device = build_device_context(cfg.app_version)
|
|
58
|
+
self.transport = Transport(cfg)
|
|
59
|
+
self.queue = EventQueue(cfg, flush_fn=self._flush_batch)
|
|
60
|
+
self.session: Session | None = None
|
|
61
|
+
self._closed = False
|
|
62
|
+
|
|
63
|
+
self.store: OfflineStore | None = None
|
|
64
|
+
if cfg.offline_storage and cfg.api_key:
|
|
65
|
+
try:
|
|
66
|
+
self.store = OfflineStore(
|
|
67
|
+
cfg.offline_storage_path,
|
|
68
|
+
max_events=cfg.max_offline_events,
|
|
69
|
+
max_bytes=cfg.max_offline_bytes,
|
|
70
|
+
)
|
|
71
|
+
# Best-effort: flush anything left over from a previous run.
|
|
72
|
+
self._replay_offline()
|
|
73
|
+
except Exception as e: # never let storage break init
|
|
74
|
+
log.warning("gamepulse: offline storage init failed: %s", e)
|
|
75
|
+
self.store = None
|
|
76
|
+
|
|
77
|
+
atexit.register(self.shutdown)
|
|
78
|
+
|
|
79
|
+
if cfg.debug:
|
|
80
|
+
import logging as _logging
|
|
81
|
+
_handler = _logging.StreamHandler()
|
|
82
|
+
_handler.setFormatter(_logging.Formatter("%(asctime)s [gamepulse] %(levelname)s %(message)s"))
|
|
83
|
+
log.addHandler(_handler)
|
|
84
|
+
log.setLevel(_logging.DEBUG)
|
|
85
|
+
|
|
86
|
+
if cfg.enable_crash_capture and cfg.api_key:
|
|
87
|
+
from gamepulse import crash
|
|
88
|
+
crash.install(self)
|
|
89
|
+
|
|
90
|
+
# ---- lifecycle ----
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def initialize(cls, cfg: SDKConfig) -> GamePulseClient:
|
|
94
|
+
with cls._lock:
|
|
95
|
+
if cls._instance is not None:
|
|
96
|
+
cls._instance.shutdown()
|
|
97
|
+
cls._instance = cls(cfg)
|
|
98
|
+
return cls._instance
|
|
99
|
+
|
|
100
|
+
def shutdown(self) -> None:
|
|
101
|
+
if self._closed:
|
|
102
|
+
return
|
|
103
|
+
self._closed = True
|
|
104
|
+
try:
|
|
105
|
+
if self.session and self.session.id and not self.session.ended_at:
|
|
106
|
+
self.end_session(end_reason="timeout")
|
|
107
|
+
self.queue.shutdown()
|
|
108
|
+
finally:
|
|
109
|
+
self.transport.close()
|
|
110
|
+
|
|
111
|
+
# ---- session ----
|
|
112
|
+
|
|
113
|
+
def start_session(self, **_: Any) -> Session:
|
|
114
|
+
now = datetime.now(UTC)
|
|
115
|
+
if not self.cfg.player_id:
|
|
116
|
+
log.debug("gamepulse: start_session without player_id")
|
|
117
|
+
self.session = Session.new_local()
|
|
118
|
+
return self.session
|
|
119
|
+
|
|
120
|
+
req = SessionStartRequest(
|
|
121
|
+
player_external_id=self.cfg.player_id,
|
|
122
|
+
started_at=now,
|
|
123
|
+
device=self.device,
|
|
124
|
+
app_version=self.cfg.app_version,
|
|
125
|
+
)
|
|
126
|
+
resp = self.transport.post("/v1/sessions/start", req.model_dump(mode="json"))
|
|
127
|
+
if resp is None or resp.status_code >= 300:
|
|
128
|
+
status_info = f"HTTP {resp.status_code}" if resp is not None else "network error"
|
|
129
|
+
log.warning("gamepulse: start_session failed (%s) — using local session", status_info)
|
|
130
|
+
_fire_error(self.cfg, status_info, 0)
|
|
131
|
+
self.session = Session.new_local()
|
|
132
|
+
return self.session
|
|
133
|
+
data = resp.json()
|
|
134
|
+
self.session = Session(
|
|
135
|
+
id=UUID(data["session_id"]),
|
|
136
|
+
player_id=UUID(data["player_id"]),
|
|
137
|
+
started_at=now,
|
|
138
|
+
)
|
|
139
|
+
return self.session
|
|
140
|
+
|
|
141
|
+
def end_session(self, end_reason: str = "normal") -> None:
|
|
142
|
+
if not self.session or not self.session.id:
|
|
143
|
+
self.session = None
|
|
144
|
+
return
|
|
145
|
+
now = datetime.now(UTC)
|
|
146
|
+
try:
|
|
147
|
+
reason = SessionEndReason(end_reason)
|
|
148
|
+
except ValueError:
|
|
149
|
+
reason = SessionEndReason.NORMAL
|
|
150
|
+
req = SessionEndRequest(session_id=self.session.id, ended_at=now, end_reason=reason)
|
|
151
|
+
resp = self.transport.post("/v1/sessions/end", req.model_dump(mode="json"))
|
|
152
|
+
if resp is not None and resp.status_code >= 300:
|
|
153
|
+
log.warning("gamepulse: end_session -> HTTP %d", resp.status_code)
|
|
154
|
+
_fire_error(self.cfg, f"HTTP {resp.status_code}", 0)
|
|
155
|
+
self.session.ended_at = now
|
|
156
|
+
self.session.end_reason = end_reason
|
|
157
|
+
self.session = None
|
|
158
|
+
|
|
159
|
+
# ---- events ----
|
|
160
|
+
|
|
161
|
+
def track(self, event_name: str, **payload: Any) -> None:
|
|
162
|
+
try:
|
|
163
|
+
category, name = (
|
|
164
|
+
event_name.split(".", 1) if "." in event_name else ("custom", event_name)
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
category_enum = EventCategory(category)
|
|
168
|
+
except ValueError:
|
|
169
|
+
category_enum = EventCategory.CUSTOM
|
|
170
|
+
ev = BaseEvent(
|
|
171
|
+
type=f"{category_enum.value}.{name}",
|
|
172
|
+
category=category_enum,
|
|
173
|
+
name=name,
|
|
174
|
+
session_id=self.session.id if self.session else None,
|
|
175
|
+
player_id=self.cfg.player_id,
|
|
176
|
+
payload=payload,
|
|
177
|
+
sdk_version=CORE_VERSION,
|
|
178
|
+
)
|
|
179
|
+
self.queue.put(ev)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
log.warning("gamepulse: track failed: %s", e)
|
|
182
|
+
|
|
183
|
+
def identify(self, player_id: str, **attributes: Any) -> None:
|
|
184
|
+
self.cfg.player_id = player_id
|
|
185
|
+
if not self.cfg.api_key:
|
|
186
|
+
return
|
|
187
|
+
log.debug("gamepulse: identify player_id=%s attrs=%s", player_id, list(attributes.keys()))
|
|
188
|
+
resp = self.transport.post(
|
|
189
|
+
"/v1/players/identify",
|
|
190
|
+
{"external_id": player_id, "attributes": attributes},
|
|
191
|
+
)
|
|
192
|
+
if resp is None:
|
|
193
|
+
log.warning("gamepulse: identify failed — no response (network error or retries exhausted)")
|
|
194
|
+
_fire_error(self.cfg, "network error", 0)
|
|
195
|
+
elif resp.status_code >= 400:
|
|
196
|
+
log.warning("gamepulse: identify -> HTTP %d", resp.status_code)
|
|
197
|
+
_fire_error(self.cfg, f"HTTP {resp.status_code}", 0)
|
|
198
|
+
else:
|
|
199
|
+
log.debug("gamepulse: identify -> HTTP %d", resp.status_code)
|
|
200
|
+
|
|
201
|
+
def flush(self, timeout_s: float | None = None) -> None:
|
|
202
|
+
self.queue.flush(timeout_s)
|
|
203
|
+
|
|
204
|
+
# ---- internal ----
|
|
205
|
+
|
|
206
|
+
def _post_outcome(self, path: str, payload: dict[str, Any]) -> tuple[str, int | None]:
|
|
207
|
+
"""Send one request and classify the result.
|
|
208
|
+
|
|
209
|
+
Returns ``(outcome, http_status_or_None)`` where outcome is one of:
|
|
210
|
+
``_OK`` (2xx accepted), ``_DROP`` (4xx permanent client error — never
|
|
211
|
+
retry), or ``_RETRY`` (network error, 5xx — worth persisting and retrying
|
|
212
|
+
later). ``http_status_or_None`` is None when all retries were exhausted
|
|
213
|
+
without a response (pure network failure).
|
|
214
|
+
"""
|
|
215
|
+
resp = self.transport.post(path, payload)
|
|
216
|
+
if resp is None:
|
|
217
|
+
return _RETRY, None
|
|
218
|
+
if resp.status_code < 300:
|
|
219
|
+
return _OK, resp.status_code
|
|
220
|
+
if 400 <= resp.status_code < 500:
|
|
221
|
+
return _DROP, resp.status_code
|
|
222
|
+
return _RETRY, resp.status_code
|
|
223
|
+
|
|
224
|
+
def _send_events(self, player_external_id: str, events: list[BaseEvent]) -> tuple[str, int | None]:
|
|
225
|
+
req = BatchEventsRequest(
|
|
226
|
+
player_external_id=player_external_id,
|
|
227
|
+
events=events,
|
|
228
|
+
device=self.device,
|
|
229
|
+
)
|
|
230
|
+
return self._post_outcome("/v1/events/batch", req.model_dump(mode="json"))
|
|
231
|
+
|
|
232
|
+
def _send_crash(self, data: dict[str, Any]) -> tuple[str, int | None]:
|
|
233
|
+
return self._post_outcome("/v1/crashes", data)
|
|
234
|
+
|
|
235
|
+
def _flush_batch(self, events: list[BaseEvent]) -> None:
|
|
236
|
+
if not self.cfg.api_key or not self.cfg.player_id:
|
|
237
|
+
return
|
|
238
|
+
outcome, status = self._send_events(self.cfg.player_id, events)
|
|
239
|
+
if outcome == _RETRY and self.store is not None:
|
|
240
|
+
self._persist_events(events)
|
|
241
|
+
elif outcome == _DROP:
|
|
242
|
+
log.warning("gamepulse: batch rejected (4xx) — dropping %d event(s)", len(events))
|
|
243
|
+
if outcome != _OK:
|
|
244
|
+
_fire_error(self.cfg, f"HTTP {status}" if status else "network error", len(events))
|
|
245
|
+
|
|
246
|
+
def _persist_events(self, events: list[BaseEvent]) -> None:
|
|
247
|
+
try:
|
|
248
|
+
self.store.append_events( # type: ignore[union-attr]
|
|
249
|
+
[(str(ev.event_id), ev.model_dump(mode="json")) for ev in events]
|
|
250
|
+
)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
log.warning("gamepulse: failed to persist events offline: %s", e)
|
|
253
|
+
|
|
254
|
+
def _report_crash(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
fingerprint: str,
|
|
258
|
+
exc_type: str,
|
|
259
|
+
message: str | None,
|
|
260
|
+
stacktrace: str,
|
|
261
|
+
occurred_at: datetime,
|
|
262
|
+
) -> None:
|
|
263
|
+
if not self.cfg.api_key or not self.cfg.player_id:
|
|
264
|
+
return
|
|
265
|
+
req = CrashIngestRequest(
|
|
266
|
+
player_external_id=self.cfg.player_id,
|
|
267
|
+
session_id=self.session.id if self.session else None,
|
|
268
|
+
fingerprint=fingerprint,
|
|
269
|
+
exc_type=exc_type,
|
|
270
|
+
message=message,
|
|
271
|
+
stacktrace=stacktrace,
|
|
272
|
+
occurred_at=occurred_at,
|
|
273
|
+
context={"app_version": self.cfg.app_version},
|
|
274
|
+
)
|
|
275
|
+
data = req.model_dump(mode="json")
|
|
276
|
+
outcome, status = self._send_crash(data)
|
|
277
|
+
if outcome == _RETRY and self.store is not None:
|
|
278
|
+
# Crash reports are high-value — persist so they survive the process
|
|
279
|
+
# ending while offline. The id is stable across retries, so re-reading
|
|
280
|
+
# the same crash next launch maps to one store entry.
|
|
281
|
+
cid = f"{fingerprint}:{occurred_at.isoformat()}"
|
|
282
|
+
try:
|
|
283
|
+
self.store.append_crashes([(cid, data)])
|
|
284
|
+
except Exception as e:
|
|
285
|
+
log.warning("gamepulse: failed to persist crash offline: %s", e)
|
|
286
|
+
if outcome != _OK:
|
|
287
|
+
_fire_error(self.cfg, f"HTTP {status}" if status else "network error", 0)
|
|
288
|
+
|
|
289
|
+
# ---- offline replay ----
|
|
290
|
+
|
|
291
|
+
def _replay_offline(self) -> None:
|
|
292
|
+
"""Best-effort: re-send anything left on disk from a previous run."""
|
|
293
|
+
try:
|
|
294
|
+
self._replay_events()
|
|
295
|
+
except Exception as e:
|
|
296
|
+
log.warning("gamepulse: offline event replay failed: %s", e)
|
|
297
|
+
try:
|
|
298
|
+
self._replay_crashes()
|
|
299
|
+
except Exception as e:
|
|
300
|
+
log.warning("gamepulse: offline crash replay failed: %s", e)
|
|
301
|
+
|
|
302
|
+
def _replay_events(self) -> None:
|
|
303
|
+
assert self.store is not None
|
|
304
|
+
pending = self.store.load_events()
|
|
305
|
+
if not pending or not self.cfg.api_key:
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
resolved: set[str] = set() # ids to remove (sent, or permanently unsendable)
|
|
309
|
+
by_player: dict[str, list[tuple[str, dict]]] = defaultdict(list)
|
|
310
|
+
for rec_id, data in pending:
|
|
311
|
+
pid = data.get("player_id") or self.cfg.player_id
|
|
312
|
+
if pid:
|
|
313
|
+
by_player[pid].append((rec_id, data))
|
|
314
|
+
else:
|
|
315
|
+
resolved.add(rec_id) # no player → can never send; drop it
|
|
316
|
+
|
|
317
|
+
for pid, items in by_player.items():
|
|
318
|
+
stop = False
|
|
319
|
+
for chunk in _chunks(items, self.cfg.batch_size):
|
|
320
|
+
events: list[BaseEvent] = []
|
|
321
|
+
chunk_ids: list[str] = []
|
|
322
|
+
for rec_id, data in chunk:
|
|
323
|
+
try:
|
|
324
|
+
events.append(BaseEvent.model_validate(data))
|
|
325
|
+
chunk_ids.append(rec_id)
|
|
326
|
+
except Exception:
|
|
327
|
+
resolved.add(rec_id) # corrupt record → drop, don't retry forever
|
|
328
|
+
if not events:
|
|
329
|
+
continue
|
|
330
|
+
outcome, _ = self._send_events(pid, events)
|
|
331
|
+
if outcome == _OK:
|
|
332
|
+
resolved.update(chunk_ids)
|
|
333
|
+
elif outcome == _DROP:
|
|
334
|
+
resolved.update(chunk_ids) # permanent — drop so it can't block the queue
|
|
335
|
+
log.warning("gamepulse: dropping %d unsendable offline event(s)", len(chunk_ids))
|
|
336
|
+
else: # _RETRY — still offline, leave the rest for next launch
|
|
337
|
+
stop = True
|
|
338
|
+
break
|
|
339
|
+
if stop:
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
if resolved:
|
|
343
|
+
self.store.remove_events(resolved)
|
|
344
|
+
|
|
345
|
+
def _replay_crashes(self) -> None:
|
|
346
|
+
assert self.store is not None
|
|
347
|
+
pending = self.store.load_crashes()
|
|
348
|
+
if not pending or not self.cfg.api_key:
|
|
349
|
+
return
|
|
350
|
+
done: set[str] = set()
|
|
351
|
+
for rec_id, data in pending:
|
|
352
|
+
outcome, _ = self._send_crash(data)
|
|
353
|
+
if outcome in (_OK, _DROP):
|
|
354
|
+
done.add(rec_id) # sent, or permanently rejected — stop tracking it
|
|
355
|
+
else: # _RETRY — still offline, retry the rest next launch
|
|
356
|
+
break
|
|
357
|
+
if done:
|
|
358
|
+
self.store.remove_crashes(done)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def get_client() -> GamePulseClient:
|
|
362
|
+
if GamePulseClient._instance is None:
|
|
363
|
+
GamePulseClient.initialize(SDKConfig(api_key=None))
|
|
364
|
+
assert GamePulseClient._instance is not None
|
|
365
|
+
return GamePulseClient._instance
|
gamepulse/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from gamepulse_core.constants import DEFAULT_BATCH_SIZE, DEFAULT_FLUSH_INTERVAL_S
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class SDKConfig:
|
|
11
|
+
api_key: str | None
|
|
12
|
+
project: str | None = None
|
|
13
|
+
player_id: str | None = None
|
|
14
|
+
api_url: str = ""
|
|
15
|
+
|
|
16
|
+
# transport
|
|
17
|
+
timeout_s: float = 10.0
|
|
18
|
+
max_retries: int = 3
|
|
19
|
+
backoff_base_s: float = 0.5
|
|
20
|
+
|
|
21
|
+
# queue
|
|
22
|
+
batch_size: int = DEFAULT_BATCH_SIZE
|
|
23
|
+
flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S
|
|
24
|
+
max_queue_size: int = 10_000
|
|
25
|
+
|
|
26
|
+
# behavior
|
|
27
|
+
enable_crash_capture: bool = True
|
|
28
|
+
auto_session: bool = True
|
|
29
|
+
app_version: str | None = None
|
|
30
|
+
debug: bool = False
|
|
31
|
+
|
|
32
|
+
# offline persistence — survive restarts / API downtime
|
|
33
|
+
offline_storage: bool = False
|
|
34
|
+
offline_storage_path: str | None = None
|
|
35
|
+
max_offline_events: int = 10_000
|
|
36
|
+
max_offline_bytes: int = 5 * 1024 * 1024 # 5 MB
|
|
37
|
+
|
|
38
|
+
# error visibility — called on the sending thread when a request fails.
|
|
39
|
+
# signature: on_send_error(description: str, event_count: int)
|
|
40
|
+
# description is e.g. "HTTP 401", "HTTP 500", or "network error"
|
|
41
|
+
# event_count is 0 for non-batch calls (identify, crash)
|
|
42
|
+
on_send_error: Any = field(default=None, compare=False)
|
gamepulse/context.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import locale
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from gamepulse_core import Platform
|
|
8
|
+
from gamepulse_core.schemas import DeviceContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _detect_platform() -> Platform:
|
|
12
|
+
system = platform.system().lower()
|
|
13
|
+
return {
|
|
14
|
+
"windows": Platform.WINDOWS,
|
|
15
|
+
"darwin": Platform.MACOS,
|
|
16
|
+
"linux": Platform.LINUX,
|
|
17
|
+
}.get(system, Platform.UNKNOWN)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_device_context(app_version: str | None) -> DeviceContext:
|
|
21
|
+
try:
|
|
22
|
+
loc = locale.getlocale()[0]
|
|
23
|
+
except Exception:
|
|
24
|
+
loc = None
|
|
25
|
+
return DeviceContext(
|
|
26
|
+
platform=_detect_platform(),
|
|
27
|
+
os_version=platform.release(),
|
|
28
|
+
locale=loc,
|
|
29
|
+
python_version=sys.version.split()[0],
|
|
30
|
+
app_version=app_version,
|
|
31
|
+
)
|
gamepulse/crash.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import traceback
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from gamepulse.client import GamePulseClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fingerprint(exc_type: type[BaseException], tb: str) -> str:
|
|
15
|
+
h = hashlib.sha1()
|
|
16
|
+
h.update(exc_type.__name__.encode())
|
|
17
|
+
for line in tb.splitlines():
|
|
18
|
+
s = line.strip()
|
|
19
|
+
if s.startswith("File ") or s.startswith("in "):
|
|
20
|
+
h.update(s.encode())
|
|
21
|
+
return h.hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def install(client: GamePulseClient) -> None:
|
|
25
|
+
prev_excepthook = sys.excepthook
|
|
26
|
+
prev_thread_hook = getattr(threading, "excepthook", None)
|
|
27
|
+
|
|
28
|
+
def _send(exc_type, exc, tb):
|
|
29
|
+
try:
|
|
30
|
+
stack = "".join(traceback.format_exception(exc_type, exc, tb))
|
|
31
|
+
client._report_crash( # noqa: SLF001
|
|
32
|
+
fingerprint=_fingerprint(exc_type, stack),
|
|
33
|
+
exc_type=exc_type.__name__,
|
|
34
|
+
message=str(exc) if exc else None,
|
|
35
|
+
stacktrace=stack,
|
|
36
|
+
occurred_at=datetime.now(UTC),
|
|
37
|
+
)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def _hook(exc_type, exc, tb):
|
|
42
|
+
_send(exc_type, exc, tb)
|
|
43
|
+
prev_excepthook(exc_type, exc, tb)
|
|
44
|
+
|
|
45
|
+
def _thread_hook(args):
|
|
46
|
+
_send(args.exc_type, args.exc_value, args.exc_traceback)
|
|
47
|
+
if prev_thread_hook:
|
|
48
|
+
prev_thread_hook(args)
|
|
49
|
+
|
|
50
|
+
sys.excepthook = _hook
|
|
51
|
+
if hasattr(threading, "excepthook"):
|
|
52
|
+
threading.excepthook = _thread_hook
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from gamepulse.client import get_client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def earn(currency: str, amount: float, source: str | None = None, **extra: Any) -> None:
|
|
9
|
+
get_client().track(
|
|
10
|
+
"economy.currency_earn", currency=currency, amount=amount, source=source, **extra
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def spend(currency: str, amount: float, item: str | None = None, **extra: Any) -> None:
|
|
15
|
+
get_client().track(
|
|
16
|
+
"economy.currency_spend", currency=currency, amount=amount, item=item, **extra
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def purchase(sku: str, price: float, currency: str = "USD", **extra: Any) -> None:
|
|
21
|
+
get_client().track(
|
|
22
|
+
"economy.iap", sku=sku, price=price, currency=currency, **extra
|
|
23
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from gamepulse.client import get_client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def action(name: str, **extra: Any) -> None:
|
|
9
|
+
get_client().track("gameplay.action", action=name, **extra)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ability(name: str, **extra: Any) -> None:
|
|
13
|
+
get_client().track("gameplay.ability_used", ability=name, **extra)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def score(value: float, **extra: Any) -> None:
|
|
17
|
+
get_client().track("gameplay.score", value=value, **extra)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from gamepulse.client import get_client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def start(level: int | str, **extra: Any) -> None:
|
|
9
|
+
get_client().track("progression.level_start", level=level, **extra)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def complete(level: int | str, stars: int | None = None, **extra: Any) -> None:
|
|
13
|
+
get_client().track("progression.level_complete", level=level, stars=stars, **extra)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fail(level: int | str, reason: str | None = None, **extra: Any) -> None:
|
|
17
|
+
get_client().track("progression.level_fail", level=level, reason=reason, **extra)
|
gamepulse/py.typed
ADDED
|
File without changes
|