mooring 0.2.2__tar.gz → 0.2.3__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.
Files changed (63) hide show
  1. {mooring-0.2.2 → mooring-0.2.3}/PKG-INFO +1 -1
  2. {mooring-0.2.2 → mooring-0.2.3}/docs/admins/build-and-distribute.md +4 -0
  3. {mooring-0.2.2 → mooring-0.2.3}/docs/admins/configuration.md +59 -0
  4. {mooring-0.2.2 → mooring-0.2.3}/pyproject.toml +1 -1
  5. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/__init__.py +1 -1
  6. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/cli.py +77 -18
  7. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/config.py +5 -0
  8. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/config_default.toml +10 -0
  9. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/server.py +30 -6
  10. mooring-0.2.3/src/mooring/telemetry.py +243 -0
  11. {mooring-0.2.2 → mooring-0.2.3}/tests/conftest.py +19 -0
  12. {mooring-0.2.2 → mooring-0.2.3}/tests/test_cli_repo.py +19 -1
  13. {mooring-0.2.2 → mooring-0.2.3}/tests/test_config.py +30 -0
  14. mooring-0.2.3/tests/test_telemetry.py +183 -0
  15. {mooring-0.2.2 → mooring-0.2.3}/uv.lock +1 -1
  16. {mooring-0.2.2 → mooring-0.2.3}/.github/workflows/docs.yml +0 -0
  17. {mooring-0.2.2 → mooring-0.2.3}/.github/workflows/release.yml +0 -0
  18. {mooring-0.2.2 → mooring-0.2.3}/.gitignore +0 -0
  19. {mooring-0.2.2 → mooring-0.2.3}/README.md +0 -0
  20. {mooring-0.2.2 → mooring-0.2.3}/docs/admins/github-setup.md +0 -0
  21. {mooring-0.2.2 → mooring-0.2.3}/docs/admins/index.md +0 -0
  22. {mooring-0.2.2 → mooring-0.2.3}/docs/assets/images/anchor-mark.svg +0 -0
  23. {mooring-0.2.2 → mooring-0.2.3}/docs/assets/images/favicon.svg +0 -0
  24. {mooring-0.2.2 → mooring-0.2.3}/docs/assets/javascripts/landing.js +0 -0
  25. {mooring-0.2.2 → mooring-0.2.3}/docs/assets/stylesheets/landing.css +0 -0
  26. {mooring-0.2.2 → mooring-0.2.3}/docs/assets/stylesheets/oah-theme.css +0 -0
  27. {mooring-0.2.2 → mooring-0.2.3}/docs/developers/contributing.md +0 -0
  28. {mooring-0.2.2 → mooring-0.2.3}/docs/developers/index.md +0 -0
  29. {mooring-0.2.2 → mooring-0.2.3}/docs/index.md +0 -0
  30. {mooring-0.2.2 → mooring-0.2.3}/docs/users/cli.md +0 -0
  31. {mooring-0.2.2 → mooring-0.2.3}/docs/users/conflicts.md +0 -0
  32. {mooring-0.2.2 → mooring-0.2.3}/docs/users/daily-workflow.md +0 -0
  33. {mooring-0.2.2 → mooring-0.2.3}/docs/users/index.md +0 -0
  34. {mooring-0.2.2 → mooring-0.2.3}/docs/users/power-bi.md +0 -0
  35. {mooring-0.2.2 → mooring-0.2.3}/overrides/home.html +0 -0
  36. {mooring-0.2.2 → mooring-0.2.3}/scripts/release.ps1 +0 -0
  37. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/auth.py +0 -0
  38. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/config_store.py +0 -0
  39. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/editor.py +0 -0
  40. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/githost.py +0 -0
  41. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/github.py +0 -0
  42. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/gitsha.py +0 -0
  43. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/__init__.py +0 -0
  44. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/static/app.js +0 -0
  45. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/static/index.html +0 -0
  46. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/static/style.css +0 -0
  47. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/manifest.py +0 -0
  48. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/notebook_template.py +0 -0
  49. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/paths.py +0 -0
  50. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/pbip.py +0 -0
  51. {mooring-0.2.2 → mooring-0.2.3}/src/mooring/sync.py +0 -0
  52. {mooring-0.2.2 → mooring-0.2.3}/tests/manual_editor_check.py +0 -0
  53. {mooring-0.2.2 → mooring-0.2.3}/tests/test_auth.py +0 -0
  54. {mooring-0.2.2 → mooring-0.2.3}/tests/test_config_store.py +0 -0
  55. {mooring-0.2.2 → mooring-0.2.3}/tests/test_githost.py +0 -0
  56. {mooring-0.2.2 → mooring-0.2.3}/tests/test_github.py +0 -0
  57. {mooring-0.2.2 → mooring-0.2.3}/tests/test_gitsha.py +0 -0
  58. {mooring-0.2.2 → mooring-0.2.3}/tests/test_hub.py +0 -0
  59. {mooring-0.2.2 → mooring-0.2.3}/tests/test_manifest.py +0 -0
  60. {mooring-0.2.2 → mooring-0.2.3}/tests/test_pbip.py +0 -0
  61. {mooring-0.2.2 → mooring-0.2.3}/tests/test_sync.py +0 -0
  62. {mooring-0.2.2 → mooring-0.2.3}/tests/test_truststore.py +0 -0
  63. {mooring-0.2.2 → mooring-0.2.3}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mooring
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Git-free marimo notebook sharing via GitHub
5
5
  Requires-Python: <3.13,>=3.12
6
6
  Requires-Dist: altair
@@ -27,6 +27,10 @@ Edit `src/mooring/config_default.toml` with your `client_id`, `owner`, `repo`,
27
27
  and `branch` (and adjust `[sync]` limits if needed) so analysts get a
28
28
  ready-to-use app. See [Configuration](configuration.md) for every key.
29
29
 
30
+ To watch usage and field errors across your team, set `[logging] endpoint` to a
31
+ collector URL or a shared folder/UNC path — see
32
+ [Central logging](configuration.md#central-logging). Leave it empty to disable.
33
+
30
34
  ## 2. Build the artifacts
31
35
 
32
36
  ```bash
@@ -83,6 +83,13 @@ direction.
83
83
  |-----|---------|---------|
84
84
  | `path` | `""` | Override the workspace location (single-repo form; with `[repos]`, use the per-repo `workspace` key). Empty means `~/Documents/mooring/<owner>/<repo>`. Supports `~` expansion. |
85
85
 
86
+ ### `[logging]`
87
+
88
+ | Key | Default | Meaning |
89
+ |-----|---------|---------|
90
+ | `endpoint` | `""` | Where to send usage/error events. Empty disables logging. See [Central logging](#central-logging) for the auto-detected URL-vs-path behaviour. |
91
+ | `level` | `"info"` | `"info"` logs usage events **and** errors; `"error"` logs only errors. |
92
+
86
93
  ## The packaged default file
87
94
 
88
95
  `src/mooring/config_default.toml` is baked into every build. Edit it **before
@@ -102,6 +109,10 @@ max_file_mb = 45
102
109
 
103
110
  [workspace]
104
111
  path = "" # empty = ~/Documents/mooring/<owner>/<repo>
112
+
113
+ [logging]
114
+ endpoint = "" # optional; see Central logging below
115
+ level = "info"
105
116
  ```
106
117
 
107
118
  To bake **several** repos in, use the `[repos]` form shown above instead of
@@ -172,10 +183,58 @@ integration testing and CI, but work anywhere:
172
183
  | `MOORING_WORKSPACE` | The active repo's workspace path |
173
184
  | `MOORING_TOKEN` | The stored auth token — set this to skip device-flow login entirely (a personal access token works). |
174
185
  | `MOORING_TRUSTSTORE` | Set to `0` to disable [OS trust store TLS verification](#corporate-networks-tls) and fall back to the bundled CA list. |
186
+ | `MOORING_LOG_ENDPOINT` | `[logging] endpoint` — the central log destination (see [Central logging](#central-logging)). |
187
+ | `MOORING_LOG_LEVEL` | `[logging] level` — `info` or `error`. |
175
188
 
176
189
  See [Contributing](../developers/contributing.md#integration-testing) for using
177
190
  these to test against a scratch repo.
178
191
 
192
+ ## Central logging
193
+
194
+ Set `[logging] endpoint` (baked into the build, or in a user `config.toml`) to
195
+ collect a record of how the app is used and what fails, from every copy, in one
196
+ place. It is **off by default** — no endpoint, no logging. When an endpoint is
197
+ set, logging is always on for users (there is no per-user off switch).
198
+
199
+ The value is **auto-detected**:
200
+
201
+ - An `http://` / `https://` URL → each event is **POSTed as JSON** to that URL.
202
+ HTTPS uses the OS trust store like the rest of the app, so a corporate
203
+ proxy's root CA is honoured automatically.
204
+ - Anything else is treated as a **folder or UNC path** → events are appended to
205
+ a per-user file `<os-user>@<host>.jsonl` in that folder (e.g.
206
+ `\\fileserver\share\mooring-logs`). One file per user means no write
207
+ contention between teammates on a shared drive.
208
+
209
+ ```toml
210
+ [logging]
211
+ endpoint = "https://collector.example.com/mooring" # or \\server\share\mooring-logs
212
+ level = "info" # "info" = usage + errors; "error" = errors only
213
+ ```
214
+
215
+ ### What gets logged
216
+
217
+ Each event is one JSON object: a UTC timestamp, the event name, identity, and a
218
+ few event-specific fields. Identity is **OS username, hostname, app version, OS,
219
+ Python version, and the GitHub login** (added once the user has logged in).
220
+
221
+ ```json
222
+ {"ts":"2026-06-13T12:34:56.789Z","event":"push","version":"0.2.2",
223
+ "os_user":"jdoe","host":"FIN-LT-042","os":"Windows-11-10.0.26200",
224
+ "python":"3.12.4","user":"octocat","pushed":3,"conflicts":0,"lines":4}
225
+ ```
226
+
227
+ Events cover the app/command start, login/logout, repo add/switch/remove,
228
+ pull/push/propose (with counts), open/new, and errors (`event:"error"` with the
229
+ exception type and message). **No file contents, file paths, or full tracebacks
230
+ are ever sent** — only counts and coarse kinds. An error message may incidentally
231
+ contain a URL or path.
232
+
233
+ Logging is strictly best-effort: it runs on a background thread, never blocks a
234
+ command, and silently drops events if the destination is slow or unreachable
235
+ (the process still exits within a few seconds). `mooring selftest` prints a
236
+ `logging` line showing the active destination.
237
+
179
238
  ## Corporate networks & TLS
180
239
 
181
240
  Mooring verifies TLS connections against the **operating system's trust
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mooring"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Git-free marimo notebook sharing via GitHub"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,3 +1,3 @@
1
1
  """Mooring: git-free marimo notebook sharing via GitHub."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -9,7 +9,7 @@ import sys
9
9
  from collections.abc import Mapping
10
10
  from pathlib import Path
11
11
 
12
- from mooring import __version__, config, paths
12
+ from mooring import __version__, config, paths, telemetry
13
13
 
14
14
  SELFTEST_PACKAGES = (
15
15
  "marimo",
@@ -162,7 +162,7 @@ def legacy_workspace_hint(cfg: config.Config) -> str:
162
162
  return ""
163
163
 
164
164
 
165
- def cmd_selftest(cfg: config.Config) -> int:
165
+ def cmd_selftest(app_cfg: config.AppConfig, cfg: config.Config) -> int:
166
166
  import importlib.metadata
167
167
 
168
168
  print(f"mooring {__version__} (python {sys.version.split()[0]}, {sys.executable})")
@@ -183,6 +183,12 @@ def cmd_selftest(cfg: config.Config) -> int:
183
183
  else "OS trust store (truststore)"
184
184
  )
185
185
  print(f" tls trust : {tls}")
186
+ log_dest = app_cfg.log_endpoint.strip()
187
+ if log_dest:
188
+ kind = "url" if log_dest.lower().startswith(("http://", "https://")) else "path"
189
+ print(f" logging : on -> {log_dest} ({kind})")
190
+ else:
191
+ print(" logging : off (no endpoint configured)")
186
192
  if cfg.is_configured:
187
193
  print(f" team repo : {cfg.repo_slug} (branch {cfg.branch}, host {cfg.host})")
188
194
  else:
@@ -230,6 +236,8 @@ def cmd_login(cfg: config.Config) -> int:
230
236
  from mooring.github import GitHubClient
231
237
 
232
238
  user = GitHubClient(token, cfg.owner, cfg.repo, host=cfg.host).get_user()
239
+ telemetry.set_user(user["login"])
240
+ telemetry.log_event("login")
233
241
  print(f"Logged in as {user['login']}.")
234
242
  return 0
235
243
 
@@ -238,6 +246,7 @@ def cmd_logout(cfg: config.Config) -> int:
238
246
  from mooring import auth
239
247
 
240
248
  auth.delete_token(host=cfg.host)
249
+ telemetry.log_event("logout")
241
250
  print("Logged out.")
242
251
  return 0
243
252
 
@@ -246,6 +255,8 @@ def cmd_whoami(cfg: config.Config) -> int:
246
255
  from mooring.github import GitHubClient
247
256
 
248
257
  user = GitHubClient(_require_token(cfg), cfg.owner, cfg.repo, host=cfg.host).get_user()
258
+ telemetry.set_user(user["login"])
259
+ telemetry.log_event("whoami")
249
260
  print(user["login"])
250
261
  return 0
251
262
 
@@ -277,6 +288,13 @@ def cmd_pull(cfg: config.Config, theirs: bool, keep_both: bool) -> int:
277
288
  else sync.ConflictStrategy.SKIP
278
289
  )
279
290
  result = sync.pull(_client(cfg), cfg, strategy=strategy)
291
+ telemetry.log_event(
292
+ "pull",
293
+ pulled=result.pulled,
294
+ conflicts=len(result.skipped_conflicts),
295
+ lines=len(result.lines),
296
+ strategy=strategy.value,
297
+ )
280
298
  for line in result.lines:
281
299
  print(f" {line}")
282
300
  print(result.summary())
@@ -287,6 +305,12 @@ def cmd_push(cfg: config.Config, only_paths: list[str], message: str | None) ->
287
305
  from mooring import sync
288
306
 
289
307
  result = sync.push(_client(cfg), cfg, paths=only_paths or None, message=message)
308
+ telemetry.log_event(
309
+ "push",
310
+ pushed=result.pushed,
311
+ conflicts=len(result.blocked_conflicts),
312
+ lines=len(result.lines),
313
+ )
290
314
  for line in result.lines:
291
315
  print(f" {line}")
292
316
  print(result.summary())
@@ -297,6 +321,12 @@ def cmd_propose(cfg: config.Config, only_paths: list[str], message: str | None)
297
321
  from mooring import sync
298
322
 
299
323
  result = sync.propose(_client(cfg), cfg, paths=only_paths or None, message=message)
324
+ telemetry.log_event(
325
+ "propose",
326
+ proposed=result.proposed,
327
+ conflicts=len(result.blocked_conflicts),
328
+ review_branch=bool(result.review_branch),
329
+ )
300
330
  for line in result.lines:
301
331
  print(f" {line}")
302
332
  print(result.summary())
@@ -319,11 +349,13 @@ def cmd_open(cfg: config.Config, rel_path: str) -> int:
319
349
  pbip.launch(target)
320
350
  except pbip.PbipLaunchError as exc:
321
351
  sys.exit(str(exc))
352
+ telemetry.log_event("open", kind="pbip")
322
353
  print(f"Opened {rel_path} in Power BI Desktop.")
323
354
  return 0
324
355
  server = EditorServer(workspace)
325
356
  server.ensure_started()
326
357
  url = server.url_for(rel_path)
358
+ telemetry.log_event("open", kind="notebook")
327
359
  print(f"Editor running at {url} (Ctrl+C to stop)")
328
360
  webbrowser.open(url)
329
361
  try:
@@ -338,6 +370,7 @@ def cmd_new(cfg: config.Config, name: str) -> int:
338
370
 
339
371
  workspace = cfg.workspace()
340
372
  rel_path = notebook_template.create(workspace, name)
373
+ telemetry.log_event("new")
341
374
  print(f"Created {rel_path}")
342
375
  return cmd_open(cfg, rel_path)
343
376
 
@@ -368,6 +401,7 @@ def cmd_repo(app_cfg: config.AppConfig, args: argparse.Namespace) -> int:
368
401
  )
369
402
  except ValueError as exc:
370
403
  sys.exit(str(exc))
404
+ telemetry.log_event("repo_add", alias=alias)
371
405
  active = " (now active)" if not args.no_use else ""
372
406
  print(f"Registered {owner}/{repo} as {alias!r}{active}.")
373
407
  return 0
@@ -376,6 +410,7 @@ def cmd_repo(app_cfg: config.AppConfig, args: argparse.Namespace) -> int:
376
410
  config_store.set_active(args.alias)
377
411
  except KeyError:
378
412
  sys.exit(_unknown_alias(args.alias, app_cfg))
413
+ telemetry.log_event("repo_switch", alias=args.alias)
379
414
  print(f"Active repo is now {args.alias!r}.")
380
415
  return 0
381
416
  if args.repo_command == "remove":
@@ -384,6 +419,7 @@ def cmd_repo(app_cfg: config.AppConfig, args: argparse.Namespace) -> int:
384
419
  config_store.remove_repo(args.alias)
385
420
  except KeyError:
386
421
  sys.exit(_unknown_alias(args.alias, app_cfg))
422
+ telemetry.log_event("repo_remove", alias=args.alias)
387
423
  print(f"Removed {args.alias!r}. Workspace folder {ws} was kept; delete it manually.")
388
424
  return 0
389
425
  return 2
@@ -394,28 +430,20 @@ def _unknown_alias(alias: str, app_cfg: config.AppConfig) -> str:
394
430
  return f"Unknown repo alias {alias!r}. Known: {known}"
395
431
 
396
432
 
397
- def main(argv: list[str] | None = None) -> int:
398
- _inject_truststore()
399
- _ensure_child_pythonpath()
400
- parser = _build_parser()
401
- args = parser.parse_args(argv)
402
- command = args.command or "hub"
403
- try:
404
- app_cfg = config.load_app_config()
405
- except ValueError as exc: # e.g. a malformed [github] host
406
- sys.exit(str(exc))
407
- try:
408
- cfg = app_cfg.config_for(getattr(args, "repo", None))
409
- except KeyError:
410
- sys.exit(_unknown_alias(args.repo, app_cfg))
411
-
433
+ def _dispatch(
434
+ parser: argparse.ArgumentParser,
435
+ command: str,
436
+ app_cfg: config.AppConfig,
437
+ cfg: config.Config,
438
+ args: argparse.Namespace,
439
+ ) -> int:
412
440
  if command == "version":
413
441
  print(f"mooring {__version__}")
414
442
  return 0
415
443
  if command == "repo":
416
444
  return cmd_repo(app_cfg, args)
417
445
  if command == "selftest":
418
- return cmd_selftest(cfg)
446
+ return cmd_selftest(app_cfg, cfg)
419
447
  if command == "hub":
420
448
  from mooring.hub.server import run_hub
421
449
 
@@ -444,5 +472,36 @@ def main(argv: list[str] | None = None) -> int:
444
472
  return 2
445
473
 
446
474
 
475
+ def main(argv: list[str] | None = None) -> int:
476
+ _inject_truststore()
477
+ _ensure_child_pythonpath()
478
+ parser = _build_parser()
479
+ args = parser.parse_args(argv)
480
+ command = args.command or "hub"
481
+ try:
482
+ app_cfg = config.load_app_config()
483
+ except ValueError as exc: # e.g. a malformed [github] host
484
+ sys.exit(str(exc))
485
+ try:
486
+ cfg = app_cfg.config_for(getattr(args, "repo", None))
487
+ except KeyError:
488
+ sys.exit(_unknown_alias(args.repo, app_cfg))
489
+
490
+ telemetry.configure(
491
+ app_cfg.log_endpoint,
492
+ identity=telemetry.base_identity(),
493
+ level=app_cfg.log_level,
494
+ )
495
+ telemetry.log_event("app_start", command=command)
496
+
497
+ try:
498
+ return _dispatch(parser, command, app_cfg, cfg, args)
499
+ except SystemExit:
500
+ raise # user-facing errors (sys.exit / argparse) are not app failures
501
+ except BaseException as exc: # noqa: BLE001 - record genuine failures, then re-raise
502
+ telemetry.log_error(exc=exc, command=command)
503
+ raise
504
+
505
+
447
506
  if __name__ == "__main__":
448
507
  sys.exit(main())
@@ -66,6 +66,8 @@ class AppConfig:
66
66
  folders: tuple[str, ...] = ("notebooks", "data", "reports")
67
67
  warn_file_mb: int = 10
68
68
  max_file_mb: int = 45
69
+ log_endpoint: str = ""
70
+ log_level: str = "info"
69
71
 
70
72
  @property
71
73
  def aliases(self) -> list[str]:
@@ -174,6 +176,7 @@ def load_app_config(
174
176
  gh = data.get("github", {})
175
177
  sync = data.get("sync", {})
176
178
  ws = data.get("workspace", {})
179
+ log = data.get("logging", {})
177
180
 
178
181
  specs, active = repo_specs_from_data(data)
179
182
  if env.get("MOORING_ACTIVE_REPO") in {s.alias for s in specs}:
@@ -211,6 +214,8 @@ def load_app_config(
211
214
  folders=tuple(sync.get("folders", ("notebooks", "data", "reports"))),
212
215
  warn_file_mb=int(sync.get("warn_file_mb", 10)),
213
216
  max_file_mb=int(sync.get("max_file_mb", 45)),
217
+ log_endpoint=env.get("MOORING_LOG_ENDPOINT", str(log.get("endpoint", ""))),
218
+ log_level=env.get("MOORING_LOG_LEVEL", str(log.get("level", "info"))),
214
219
  )
215
220
 
216
221
 
@@ -30,3 +30,13 @@ max_file_mb = 45
30
30
 
31
31
  [workspace]
32
32
  path = "" # empty = ~/Documents/mooring/<owner>/<repo>
33
+
34
+ # Central logging. Set `endpoint` to collect usage events and errors from every
35
+ # copy of the app in one place. The value is auto-detected:
36
+ # http(s)://... -> each event is POSTed as JSON
37
+ # anything else -> treated as a folder/UNC path; events are appended as a
38
+ # per-user JSONL file (e.g. \\server\share\mooring-logs).
39
+ # Empty = disabled (the shipped default). When set, it is always on for users.
40
+ [logging]
41
+ endpoint = ""
42
+ level = "info" # "info" logs usage + errors; "error" logs only errors
@@ -22,7 +22,7 @@ from starlette.responses import FileResponse, JSONResponse
22
22
  from starlette.routing import Mount, Route
23
23
  from starlette.staticfiles import StaticFiles
24
24
 
25
- from mooring import __version__, auth, config, config_store, pbip, sync
25
+ from mooring import __version__, auth, config, config_store, pbip, sync, telemetry
26
26
  from mooring.cli import SELFTEST_PACKAGES, legacy_workspace_hint
27
27
  from mooring.editor import EditorServer, _free_port
28
28
  from mooring.github import AuthFailed, GitHubClient, GitHubError, compare_url
@@ -64,6 +64,7 @@ class Hub:
64
64
  def username(self) -> str:
65
65
  if not self._user_login:
66
66
  self._user_login = self.client().get_user()["login"]
67
+ telemetry.set_user(self._user_login)
67
68
  return self._user_login
68
69
 
69
70
  def ensure_editor(self) -> EditorServer:
@@ -150,6 +151,7 @@ class Hub:
150
151
  body["logged_in"] = False
151
152
  body["error"] = "Your GitHub login expired. Please log in again."
152
153
  except GitHubError as exc:
154
+ telemetry.log_error(exc=exc, op="state")
153
155
  body["error"] = str(exc)
154
156
  return JSONResponse(body)
155
157
 
@@ -177,6 +179,7 @@ class Hub:
177
179
  except ValueError as exc:
178
180
  return JSONResponse({"error": str(exc)}, status_code=400)
179
181
  self.reload()
182
+ telemetry.log_event("repo_add", alias=fields["alias"] or fields["repo"])
180
183
  return JSONResponse({"ok": True, "active_repo": self.app_cfg.active_alias})
181
184
 
182
185
  async def api_repo_switch(self, request: Request) -> JSONResponse:
@@ -187,6 +190,7 @@ class Hub:
187
190
  except KeyError:
188
191
  return JSONResponse({"error": f"Unknown repo alias {alias!r}."}, status_code=400)
189
192
  self.reload()
193
+ telemetry.log_event("repo_switch", alias=alias)
190
194
  return JSONResponse({"ok": True, "active_repo": alias})
191
195
 
192
196
  async def api_repo_remove(self, request: Request) -> JSONResponse:
@@ -198,6 +202,7 @@ class Hub:
198
202
  except KeyError:
199
203
  return JSONResponse({"error": f"Unknown repo alias {alias!r}."}, status_code=400)
200
204
  self.reload()
205
+ telemetry.log_event("repo_remove", alias=alias)
201
206
  return JSONResponse(
202
207
  {"ok": True, "lines": [f"Removed {alias!r}; workspace folder kept at {workspace}"]}
203
208
  )
@@ -235,6 +240,7 @@ class Hub:
235
240
  with self._lock:
236
241
  self._device = None
237
242
  self._user_login = ""
243
+ telemetry.log_event("login")
238
244
  return JSONResponse({"status": "ok"})
239
245
  with self._lock:
240
246
  self._poll_interval = result.interval
@@ -244,36 +250,49 @@ class Hub:
244
250
  def api_logout(self, request: Request) -> JSONResponse:
245
251
  auth.delete_token(host=self.cfg.host)
246
252
  self._user_login = ""
253
+ telemetry.log_event("logout")
247
254
  return JSONResponse({"ok": True})
248
255
 
249
256
  async def api_pull(self, request: Request) -> JSONResponse:
250
257
  data = await request.json() if await request.body() else {}
251
258
  strategy = sync.ConflictStrategy(data.get("strategy", "skip"))
252
- return self._sync_op(lambda: sync.pull(self.client(), self.cfg, strategy=strategy))
259
+ return self._sync_op("pull", lambda: sync.pull(self.client(), self.cfg, strategy=strategy))
253
260
 
254
261
  async def api_push(self, request: Request) -> JSONResponse:
255
262
  data = await request.json() if await request.body() else {}
256
263
  paths_arg = data.get("paths") or None
257
- return self._sync_op(lambda: sync.push(self.client(), self.cfg, paths=paths_arg))
264
+ return self._sync_op("push", lambda: sync.push(self.client(), self.cfg, paths=paths_arg))
258
265
 
259
266
  async def api_propose(self, request: Request) -> JSONResponse:
260
267
  data = await request.json() if await request.body() else {}
261
268
  paths_arg = data.get("paths") or None
262
- return self._sync_op(lambda: sync.propose(self.client(), self.cfg, paths=paths_arg))
269
+ return self._sync_op(
270
+ "propose", lambda: sync.propose(self.client(), self.cfg, paths=paths_arg)
271
+ )
263
272
 
264
273
  async def api_resolve(self, request: Request) -> JSONResponse:
265
274
  data = await request.json()
266
275
  strategy = sync.ConflictStrategy(data["strategy"])
267
276
  username = self.username() if strategy is sync.ConflictStrategy.PUSH_COPY else ""
268
277
  return self._sync_op(
269
- lambda: sync.resolve(self.client(), self.cfg, data["path"], strategy, username)
278
+ "resolve",
279
+ lambda: sync.resolve(self.client(), self.cfg, data["path"], strategy, username),
270
280
  )
271
281
 
272
- def _sync_op(self, op) -> JSONResponse:
282
+ def _sync_op(self, name: str, op) -> JSONResponse:
273
283
  try:
274
284
  result = op()
275
285
  except (GitHubError, OSError) as exc:
286
+ telemetry.log_error(exc=exc, op=name)
276
287
  return JSONResponse({"error": str(exc)}, status_code=502)
288
+ telemetry.log_event(
289
+ name,
290
+ pulled=result.pulled,
291
+ pushed=result.pushed,
292
+ proposed=result.proposed,
293
+ conflicts=len(result.skipped_conflicts) + len(result.blocked_conflicts),
294
+ lines=len(result.lines),
295
+ )
277
296
  body = {"lines": result.lines, "summary": result.summary()}
278
297
  if result.review_branch:
279
298
  body["review_branch"] = result.review_branch
@@ -288,6 +307,7 @@ class Hub:
288
307
  rel_path = notebook_template.create(self.cfg.workspace(), data.get("name", ""))
289
308
  except (ValueError, FileExistsError) as exc:
290
309
  return JSONResponse({"error": str(exc)}, status_code=400)
310
+ telemetry.log_event("new")
291
311
  return self._open(rel_path)
292
312
 
293
313
  async def api_open(self, request: Request) -> JSONResponse:
@@ -309,6 +329,7 @@ class Hub:
309
329
  except pbip.PbipLaunchError as exc:
310
330
  return JSONResponse({"error": str(exc)}, status_code=400)
311
331
  name = rel_path.rsplit("/", 1)[-1]
332
+ telemetry.log_event("open", kind="pbip")
312
333
  return JSONResponse(
313
334
  {"path": rel_path, "lines": [f"Opened {name} in Power BI Desktop"]}
314
335
  )
@@ -321,6 +342,7 @@ class Hub:
321
342
  editor = self.ensure_editor()
322
343
  except Exception as exc: # noqa: BLE001 - shown in the UI
323
344
  return JSONResponse({"error": f"Could not start the editor: {exc}"}, status_code=502)
345
+ telemetry.log_event("open", kind="notebook")
324
346
  return JSONResponse({"path": rel_path, "url": editor.url_for(rel_path)})
325
347
 
326
348
 
@@ -333,6 +355,7 @@ def create_app(hub: Hub) -> Starlette:
333
355
  yield
334
356
  finally:
335
357
  hub.shutdown()
358
+ telemetry.flush(timeout=3.0)
336
359
 
337
360
  return Starlette(
338
361
  routes=[
@@ -363,6 +386,7 @@ def run_hub(
363
386
  app = create_app(hub)
364
387
  port = port or _free_port()
365
388
  url = f"http://127.0.0.1:{port}/"
389
+ telemetry.log_event("hub_start")
366
390
  print(f"mooring hub running at {url} (Ctrl+C to quit)")
367
391
  if open_browser:
368
392
  threading.Timer(0.8, webbrowser.open, args=(url,)).start()
@@ -0,0 +1,243 @@
1
+ """Fire-and-forget usage/error telemetry to an admin-configured central sink.
2
+
3
+ The admin who builds the .pyz bakes a destination into ``[logging] endpoint``:
4
+ an ``http(s)://`` URL receives each event as a JSON POST; any other value is
5
+ treated as a folder/UNC path and gets a per-user JSONL file appended to it
6
+ (``<os_user>@<host>.jsonl``). An empty endpoint disables telemetry entirely —
7
+ that is the shipped default, and what keeps the test suite hermetic.
8
+
9
+ Everything here is best-effort and must never block or crash the app (the same
10
+ spirit as the truststore guard in ``cli.py``): ``log_event`` only enqueues, a
11
+ daemon thread drains the queue, and all sink I/O is wrapped so a slow or
12
+ unreachable destination can at worst drop events. An ``atexit`` flush with a
13
+ wall-clock-bounded wait makes sure a short-lived CLI run still ships its events
14
+ without an unreachable endpoint hanging the exit.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import atexit
20
+ import getpass
21
+ import json
22
+ import platform
23
+ import queue
24
+ import socket
25
+ import threading
26
+ import time
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+
30
+ from mooring import __version__
31
+
32
+ # Severity gate: "info" lets everything through, "error" keeps only errors.
33
+ _INFO = 20
34
+ _ERROR = 40
35
+ _LEVELS = {"info": _INFO, "error": _ERROR}
36
+
37
+ _lock = threading.Lock()
38
+ _queue: queue.Queue = queue.Queue()
39
+ _thread: threading.Thread | None = None
40
+ _sink = None # callable(event: dict) -> None
41
+ _identity: dict = {}
42
+ _user_login = ""
43
+ _enabled = False
44
+ _level = _INFO
45
+ _session = None # lazy/injected requests.Session for URL sinks
46
+ _atexit_registered = False
47
+
48
+
49
+ # -- sinks -------------------------------------------------------------------
50
+
51
+
52
+ class _UrlSink:
53
+ def __init__(self, url: str) -> None:
54
+ self.url = url
55
+
56
+ def __call__(self, event: dict) -> None:
57
+ # truststore.inject_into_ssl() has already run in cli.main(), so this
58
+ # POST verifies against the corporate trust store like the rest of the app.
59
+ _get_session().post(self.url, json=event, timeout=(3.05, 3))
60
+
61
+
62
+ class _PathSink:
63
+ def __init__(self, folder: str) -> None:
64
+ self.folder = Path(folder)
65
+
66
+ def __call__(self, event: dict) -> None:
67
+ self.folder.mkdir(parents=True, exist_ok=True)
68
+ name = _safe_filename(event.get("os_user", "user"), event.get("host", "host"))
69
+ # One open-append-close per event: a share blip loses at most one line,
70
+ # and the per-user filename means concurrent users never clobber each other.
71
+ with (self.folder / name).open("a", encoding="utf-8") as fh:
72
+ fh.write(json.dumps(event) + "\n")
73
+
74
+
75
+ def _safe_filename(os_user: str, host: str) -> str:
76
+ def clean(value: str) -> str:
77
+ return "".join(c for c in str(value) if c.isalnum() or c in "-_.") or "unknown"
78
+
79
+ return f"{clean(os_user)}@{clean(host)}.jsonl"
80
+
81
+
82
+ def _resolve_sink(destination: str):
83
+ dest = (destination or "").strip()
84
+ if not dest:
85
+ return None
86
+ if dest.lower().startswith(("http://", "https://")):
87
+ return _UrlSink(dest)
88
+ return _PathSink(dest)
89
+
90
+
91
+ def _get_session():
92
+ global _session
93
+ if _session is None:
94
+ import requests
95
+
96
+ _session = requests.Session()
97
+ return _session
98
+
99
+
100
+ # -- public API --------------------------------------------------------------
101
+
102
+
103
+ def base_identity() -> dict:
104
+ """The machine/app fields stamped onto every event."""
105
+ return {
106
+ "version": __version__,
107
+ "os_user": _safe(getpass.getuser),
108
+ "host": _safe(socket.gethostname),
109
+ "os": _safe(platform.platform),
110
+ "python": platform.python_version(),
111
+ }
112
+
113
+
114
+ def configure(destination: str, *, identity: dict, level: str = "info", session=None) -> None:
115
+ """Point telemetry at a destination. Blank destination = disabled (no-op).
116
+
117
+ Idempotent and total: any failure here just leaves telemetry disabled — it
118
+ must never raise into the caller.
119
+ """
120
+ global _sink, _identity, _enabled, _level, _thread, _session, _atexit_registered
121
+ try:
122
+ with _lock:
123
+ _identity = dict(identity or {})
124
+ _level = _LEVELS.get(str(level).strip().lower(), _INFO)
125
+ _session = session
126
+ sink = _resolve_sink(destination)
127
+ if sink is None:
128
+ _enabled = False
129
+ _sink = None
130
+ return
131
+ _sink = sink
132
+ _enabled = True
133
+ _ensure_thread()
134
+ if not _atexit_registered:
135
+ atexit.register(flush)
136
+ _atexit_registered = True
137
+ except Exception: # noqa: BLE001 - telemetry must never break the app
138
+ _enabled = False
139
+ _sink = None
140
+
141
+
142
+ def _ensure_thread() -> None:
143
+ """Start the single background sender once and keep it for the process.
144
+
145
+ A singleton matters: configure() runs once in production, but the test suite
146
+ reconfigures repeatedly, and two live daemons draining one queue would
147
+ reorder events. The daemon reads the module-level _sink each loop, so
148
+ swapping sinks needs no new thread.
149
+ """
150
+ global _thread
151
+ if _thread is None or not _thread.is_alive():
152
+ _thread = threading.Thread(target=_run, name="mooring-telemetry", daemon=True)
153
+ _thread.start()
154
+
155
+
156
+ def set_user(login: str) -> None:
157
+ """Record the GitHub login once it's known; overlaid onto later events."""
158
+ global _user_login
159
+ if login:
160
+ _user_login = str(login)
161
+
162
+
163
+ def log_event(name: str, **fields) -> None:
164
+ _emit(name, _INFO, fields)
165
+
166
+
167
+ def log_error(*, exc: BaseException, **fields) -> None:
168
+ """Record an error as type + message only — never a traceback."""
169
+ fields = dict(fields)
170
+ fields["error_type"] = type(exc).__name__
171
+ fields["error_msg"] = str(exc)
172
+ _emit("error", _ERROR, fields)
173
+
174
+
175
+ def flush(timeout: float = 3.0) -> None:
176
+ """Wait (at most ``timeout`` seconds) for queued events to be sent."""
177
+ try:
178
+ if not _enabled:
179
+ return
180
+ deadline = time.monotonic() + timeout
181
+ while _queue.unfinished_tasks > 0 and time.monotonic() < deadline:
182
+ time.sleep(0.02)
183
+ except Exception: # noqa: BLE001 - flushing is best-effort
184
+ pass
185
+
186
+
187
+ # -- internals ---------------------------------------------------------------
188
+
189
+
190
+ def _emit(name: str, severity: int, fields: dict) -> None:
191
+ if not _enabled or severity < _level:
192
+ return
193
+ try:
194
+ event = dict(_identity)
195
+ event["ts"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
196
+ event["event"] = name
197
+ event["user"] = _user_login
198
+ event.update(fields)
199
+ _queue.put_nowait(event)
200
+ except Exception: # noqa: BLE001 - dropping an event must never raise
201
+ pass
202
+
203
+
204
+ def _run() -> None:
205
+ while True:
206
+ item = _queue.get()
207
+ try:
208
+ sink = _sink
209
+ if item is not None and sink is not None:
210
+ sink(item)
211
+ except Exception: # noqa: BLE001 - drop the event, never die
212
+ pass
213
+ finally:
214
+ _queue.task_done()
215
+
216
+
217
+ def _safe(fn, default: str = "unknown") -> str:
218
+ try:
219
+ return str(fn())
220
+ except Exception: # noqa: BLE001
221
+ return default
222
+
223
+
224
+ def _reset_for_tests() -> None:
225
+ """Drop sink/state and drain the queue so tests don't leak into each other.
226
+
227
+ The singleton daemon is deliberately left running (it reads _sink=None now,
228
+ so it's a no-op until the next configure()).
229
+ """
230
+ global _sink, _identity, _user_login, _enabled, _level, _session
231
+ with _lock:
232
+ _enabled = False
233
+ _sink = None
234
+ _identity = {}
235
+ _user_login = ""
236
+ _level = _INFO
237
+ _session = None
238
+ try:
239
+ while True:
240
+ _queue.get_nowait()
241
+ _queue.task_done()
242
+ except queue.Empty:
243
+ pass
@@ -9,6 +9,25 @@ from mooring.github import NotFound, RefAlreadyExists, RemoteConflict, TreeEntry
9
9
  DEFAULT_BRANCH = "main"
10
10
 
11
11
 
12
+ @pytest.fixture(autouse=True)
13
+ def _hermetic_telemetry(monkeypatch):
14
+ """Keep telemetry off and isolated for the whole suite.
15
+
16
+ With no MOORING_LOG_ENDPOINT, ``telemetry.configure()`` is a no-op, so the
17
+ suite never POSTs or writes to the real log dir. Tests that want to exercise
18
+ telemetry set MOORING_LOG_ENDPOINT to a tmp path themselves. The teardown
19
+ drops the daemon/state so nothing leaks across tests.
20
+ """
21
+ monkeypatch.delenv("MOORING_LOG_ENDPOINT", raising=False)
22
+ monkeypatch.delenv("MOORING_LOG_LEVEL", raising=False)
23
+ monkeypatch.setenv("MOORING_TRUSTSTORE", "0") # main() injects into global ssl
24
+ yield
25
+ from mooring import telemetry
26
+
27
+ telemetry.flush(0.2)
28
+ telemetry._reset_for_tests()
29
+
30
+
12
31
  class FakeClient:
13
32
  def __init__(self, files: dict[str, bytes] | None = None):
14
33
  self.blobs: dict[str, bytes] = {}
@@ -1,10 +1,11 @@
1
1
  """CLI repo-management commands driven through cli.main()."""
2
2
 
3
+ import json
3
4
  import tomllib
4
5
 
5
6
  import pytest
6
7
 
7
- from mooring import cli, paths
8
+ from mooring import cli, paths, telemetry
8
9
 
9
10
 
10
11
  @pytest.fixture(autouse=True)
@@ -84,3 +85,20 @@ def test_status_with_unknown_repo_alias_exits():
84
85
  def test_repo_list_when_empty(capsys):
85
86
  assert cli.main(["repo", "list"]) == 0
86
87
  assert "No repos registered" in capsys.readouterr().out
88
+
89
+
90
+ def test_telemetry_records_events_through_main(tmp_path, monkeypatch):
91
+ """A baked path endpoint makes cli.main() emit app_start + the command event."""
92
+ logdir = tmp_path / "telemetry"
93
+ monkeypatch.setenv("MOORING_LOG_ENDPOINT", str(logdir))
94
+ assert cli.main(["repo", "add", "acme/nbs"]) == 0
95
+ telemetry.flush(2.0)
96
+ files = list(logdir.glob("*.jsonl"))
97
+ assert len(files) == 1
98
+ events = [json.loads(line) for line in files[0].read_text("utf-8").splitlines() if line.strip()]
99
+ by_name = {e["event"]: e for e in events}
100
+ assert "app_start" in by_name and "repo_add" in by_name
101
+ assert by_name["app_start"]["command"] == "repo"
102
+ assert by_name["repo_add"]["alias"] == "nbs"
103
+ assert by_name["app_start"]["ts"].endswith("Z")
104
+ assert by_name["app_start"]["version"] # identity stamped
@@ -153,6 +153,36 @@ def test_active_repo_env_override(tmp_path):
153
153
  assert app2.config_for("sandbox").branch == "dev" # untouched
154
154
 
155
155
 
156
+ # -- central logging ([logging] section) ------------------------------------
157
+
158
+
159
+ def test_logging_defaults_off(tmp_path):
160
+ app = load_app_config(user_config_path=tmp_path / "missing.toml", env={})
161
+ assert app.log_endpoint == ""
162
+ assert app.log_level == "info"
163
+
164
+
165
+ def test_logging_from_user_config(tmp_path):
166
+ user = tmp_path / "config.toml"
167
+ user.write_text(
168
+ '[logging]\nendpoint = "https://collector.example/m"\nlevel = "error"\n', "utf-8"
169
+ )
170
+ app = load_app_config(user_config_path=user, env={})
171
+ assert app.log_endpoint == "https://collector.example/m"
172
+ assert app.log_level == "error"
173
+
174
+
175
+ def test_logging_env_overrides(tmp_path):
176
+ user = tmp_path / "config.toml"
177
+ user.write_text('[logging]\nendpoint = "https://baked.example"\n', "utf-8")
178
+ app = load_app_config(
179
+ user_config_path=user,
180
+ env={"MOORING_LOG_ENDPOINT": r"\\server\share\logs", "MOORING_LOG_LEVEL": "error"},
181
+ )
182
+ assert app.log_endpoint == r"\\server\share\logs"
183
+ assert app.log_level == "error"
184
+
185
+
156
186
  def test_default_workspace_keyed_by_owner():
157
187
  a = paths.default_workspace("acme", "notebooks")
158
188
  b = paths.default_workspace("phil", "notebooks")
@@ -0,0 +1,183 @@
1
+ """Telemetry sink dispatch, identity tagging, and fail-safe behavior."""
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+
7
+ import pytest
8
+
9
+ from mooring import telemetry
10
+
11
+ IDENTITY = {
12
+ "version": "9.9.9",
13
+ "os_user": "tester",
14
+ "host": "test-host",
15
+ "os": "TestOS",
16
+ "python": "3.12.0",
17
+ }
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def _reset():
22
+ telemetry._reset_for_tests()
23
+ yield
24
+ telemetry.flush(0.5)
25
+ telemetry._reset_for_tests()
26
+
27
+
28
+ class FakeSession:
29
+ """Stand-in for a requests.Session that records POSTs."""
30
+
31
+ def __init__(self, raise_exc=None, block=None):
32
+ self.posts = []
33
+ self._raise = raise_exc
34
+ self._block = block # an Event the sender waits on, to simulate a hung POST
35
+
36
+ def post(self, url, json=None, timeout=None):
37
+ if self._block is not None:
38
+ self._block.wait(5)
39
+ if self._raise:
40
+ raise self._raise
41
+ self.posts.append((url, json, timeout))
42
+ return type("R", (), {"status_code": 200})()
43
+
44
+
45
+ def _drain():
46
+ telemetry.flush(2.0)
47
+
48
+
49
+ def test_disabled_when_no_destination(tmp_path):
50
+ telemetry.configure("", identity=IDENTITY)
51
+ telemetry.log_event("app_start", command="hub")
52
+ _drain()
53
+ assert telemetry._enabled is False
54
+ assert list(tmp_path.iterdir()) == [] # nothing written anywhere
55
+
56
+
57
+ def test_url_sink_posts_json():
58
+ sess = FakeSession()
59
+ telemetry.configure("https://collector.example/mooring", identity=IDENTITY, session=sess)
60
+ telemetry.log_event("push", pushed=3, conflicts=0, lines=4)
61
+ _drain()
62
+ assert len(sess.posts) == 1
63
+ url, body, timeout = sess.posts[0]
64
+ assert url == "https://collector.example/mooring"
65
+ assert body["event"] == "push"
66
+ assert body["pushed"] == 3
67
+ assert body["version"] == "9.9.9"
68
+ assert body["os_user"] == "tester"
69
+ assert body["host"] == "test-host"
70
+ assert body["ts"].endswith("Z")
71
+ assert timeout is not None # bounded so a hung endpoint can't stall the sender
72
+
73
+
74
+ def test_path_sink_appends_jsonl(tmp_path):
75
+ folder = tmp_path / "logs"
76
+ telemetry.configure(str(folder), identity=IDENTITY)
77
+ telemetry.log_event("app_start", command="status")
78
+ telemetry.log_event("pull", pulled=1)
79
+ _drain()
80
+ files = list(folder.glob("*.jsonl"))
81
+ assert len(files) == 1
82
+ assert files[0].name == "tester@test-host.jsonl"
83
+ lines = files[0].read_text("utf-8").strip().splitlines()
84
+ assert len(lines) == 2 # second event appends, never truncates
85
+ assert json.loads(lines[0])["command"] == "status"
86
+ assert json.loads(lines[1])["event"] == "pull"
87
+
88
+
89
+ def test_per_user_filename_sanitized(tmp_path):
90
+ ident = dict(IDENTITY, os_user="DOMAIN\\jdoe", host="host:42")
91
+ telemetry.configure(str(tmp_path), identity=ident)
92
+ telemetry.log_event("app_start", command="hub")
93
+ _drain()
94
+ files = list(tmp_path.glob("*.jsonl"))
95
+ assert len(files) == 1
96
+ name = files[0].name
97
+ assert "\\" not in name and ":" not in name and "/" not in name
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ "dest,is_url",
102
+ [
103
+ ("http://x/y", True),
104
+ ("https://x/y", True),
105
+ ("HTTPS://X/Y", True),
106
+ (r"\\srv\share\logs", False),
107
+ ("D:/logs", False),
108
+ ("/var/log/mooring", False),
109
+ ],
110
+ )
111
+ def test_url_vs_path_autodetect(dest, is_url):
112
+ sink = telemetry._resolve_sink(dest)
113
+ assert isinstance(sink, telemetry._UrlSink) is is_url
114
+
115
+
116
+ def test_identity_on_every_event(tmp_path):
117
+ telemetry.configure(str(tmp_path), identity=IDENTITY)
118
+ telemetry.log_event("a")
119
+ telemetry.log_event("b")
120
+ _drain()
121
+ lines = (tmp_path / "tester@test-host.jsonl").read_text("utf-8").strip().splitlines()
122
+ for raw in lines:
123
+ e = json.loads(raw)
124
+ assert e["version"] == "9.9.9"
125
+ assert e["os_user"] == "tester"
126
+ assert e["ts"].endswith("Z")
127
+ assert "event" in e
128
+
129
+
130
+ def test_set_user_tags_subsequent_events(tmp_path):
131
+ telemetry.configure(str(tmp_path), identity=IDENTITY)
132
+ telemetry.log_event("before")
133
+ telemetry.set_user("octocat")
134
+ telemetry.log_event("after")
135
+ _drain()
136
+ before, after = (
137
+ json.loads(raw)
138
+ for raw in (tmp_path / "tester@test-host.jsonl").read_text("utf-8").strip().splitlines()
139
+ )
140
+ assert before["user"] == ""
141
+ assert after["user"] == "octocat"
142
+
143
+
144
+ def test_swallow_on_sink_error():
145
+ sess = FakeSession(raise_exc=RuntimeError("network down"))
146
+ telemetry.configure("https://x", identity=IDENTITY, session=sess)
147
+ telemetry.log_event("push") # must not raise
148
+ _drain() # must not raise
149
+ assert sess.posts == []
150
+
151
+
152
+ def test_flush_timeout_is_bounded():
153
+ release = threading.Event()
154
+ sess = FakeSession(block=release)
155
+ telemetry.configure("https://x", identity=IDENTITY, session=sess)
156
+ telemetry.log_event("push")
157
+ start = time.monotonic()
158
+ telemetry.flush(0.2)
159
+ assert time.monotonic() - start < 1.5 # a hung POST can't stall the exit
160
+ release.set() # let the sender finish so it doesn't block later tests
161
+
162
+
163
+ def test_level_error_drops_usage_events(tmp_path):
164
+ telemetry.configure(str(tmp_path), identity=IDENTITY, level="error")
165
+ telemetry.log_event("push", pushed=1) # usage event, should be dropped
166
+ telemetry.log_error(exc=ValueError("boom")) # error, should pass
167
+ _drain()
168
+ lines = (tmp_path / "tester@test-host.jsonl").read_text("utf-8").strip().splitlines()
169
+ events = [json.loads(raw)["event"] for raw in lines]
170
+ assert events == ["error"]
171
+
172
+
173
+ def test_log_error_type_and_message_no_traceback(tmp_path):
174
+ telemetry.configure(str(tmp_path), identity=IDENTITY)
175
+ telemetry.log_error(exc=ValueError("boom"), op="push")
176
+ _drain()
177
+ event = json.loads((tmp_path / "tester@test-host.jsonl").read_text("utf-8").strip())
178
+ assert event["event"] == "error"
179
+ assert event["error_type"] == "ValueError"
180
+ assert event["error_msg"] == "boom"
181
+ assert event["op"] == "push"
182
+ assert "traceback" not in event
183
+ assert not any("Traceback" in str(v) for v in event.values())
@@ -450,7 +450,7 @@ wheels = [
450
450
 
451
451
  [[package]]
452
452
  name = "mooring"
453
- version = "0.2.2"
453
+ version = "0.2.3"
454
454
  source = { editable = "." }
455
455
  dependencies = [
456
456
  { name = "altair" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes