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.
- {mooring-0.2.2 → mooring-0.2.3}/PKG-INFO +1 -1
- {mooring-0.2.2 → mooring-0.2.3}/docs/admins/build-and-distribute.md +4 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/admins/configuration.md +59 -0
- {mooring-0.2.2 → mooring-0.2.3}/pyproject.toml +1 -1
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/__init__.py +1 -1
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/cli.py +77 -18
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/config.py +5 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/config_default.toml +10 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/server.py +30 -6
- mooring-0.2.3/src/mooring/telemetry.py +243 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/conftest.py +19 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_cli_repo.py +19 -1
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_config.py +30 -0
- mooring-0.2.3/tests/test_telemetry.py +183 -0
- {mooring-0.2.2 → mooring-0.2.3}/uv.lock +1 -1
- {mooring-0.2.2 → mooring-0.2.3}/.github/workflows/docs.yml +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/.github/workflows/release.yml +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/.gitignore +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/README.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/admins/github-setup.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/admins/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/assets/images/anchor-mark.svg +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/assets/images/favicon.svg +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/assets/javascripts/landing.js +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/assets/stylesheets/landing.css +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/assets/stylesheets/oah-theme.css +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/developers/contributing.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/developers/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/users/cli.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/users/conflicts.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/users/daily-workflow.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/users/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/docs/users/power-bi.md +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/overrides/home.html +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/scripts/release.ps1 +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/auth.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/config_store.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/editor.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/githost.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/github.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/gitsha.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/__init__.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/static/app.js +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/static/index.html +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/hub/static/style.css +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/manifest.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/notebook_template.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/paths.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/pbip.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/src/mooring/sync.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/manual_editor_check.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_auth.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_config_store.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_githost.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_github.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_gitsha.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_hub.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_manifest.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_pbip.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_sync.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/tests/test_truststore.py +0 -0
- {mooring-0.2.2 → mooring-0.2.3}/zensical.toml +0 -0
|
@@ -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
|
|
@@ -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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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(
|
|
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
|
-
|
|
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())
|
|
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
|
|
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
|