stemtrace 0.3.2__tar.gz → 0.3.4__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 (139) hide show
  1. {stemtrace-0.3.2 → stemtrace-0.3.4}/.bumpversion.toml +1 -1
  2. {stemtrace-0.3.2 → stemtrace-0.3.4}/.github/workflows/ci.yml +1 -1
  3. {stemtrace-0.3.2 → stemtrace-0.3.4}/.github/workflows/e2e.yml +4 -4
  4. {stemtrace-0.3.2 → stemtrace-0.3.4}/.github/workflows/release.yml +10 -6
  5. {stemtrace-0.3.2 → stemtrace-0.3.4}/CHANGELOG.md +15 -0
  6. {stemtrace-0.3.2 → stemtrace-0.3.4}/PKG-INFO +6 -2
  7. {stemtrace-0.3.2 → stemtrace-0.3.4}/README.md +5 -1
  8. {stemtrace-0.3.2 → stemtrace-0.3.4}/pyproject.toml +1 -1
  9. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/__init__.py +10 -1
  10. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/config.py +6 -0
  11. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/api/routes.py +57 -4
  12. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/api/websocket.py +6 -2
  13. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/extension.py +6 -0
  14. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/login_routes.py +21 -4
  15. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/router.py +2 -0
  16. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/package-lock.json +313 -96
  17. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/package.json +5 -5
  18. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_form_login.py +104 -6
  19. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_init.py +2 -0
  20. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_routes.py +141 -0
  21. {stemtrace-0.3.2 → stemtrace-0.3.4}/uv.lock +1 -1
  22. {stemtrace-0.3.2 → stemtrace-0.3.4}/.dockerignore +0 -0
  23. {stemtrace-0.3.2 → stemtrace-0.3.4}/.github/dependabot.yml +0 -0
  24. {stemtrace-0.3.2 → stemtrace-0.3.4}/.gitignore +0 -0
  25. {stemtrace-0.3.2 → stemtrace-0.3.4}/.pre-commit-config.yaml +0 -0
  26. {stemtrace-0.3.2 → stemtrace-0.3.4}/.python-version +0 -0
  27. {stemtrace-0.3.2 → stemtrace-0.3.4}/CONTRIBUTING.md +0 -0
  28. {stemtrace-0.3.2 → stemtrace-0.3.4}/Dockerfile +0 -0
  29. {stemtrace-0.3.2 → stemtrace-0.3.4}/Dockerfile.e2e +0 -0
  30. {stemtrace-0.3.2 → stemtrace-0.3.4}/LICENSE +0 -0
  31. {stemtrace-0.3.2 → stemtrace-0.3.4}/Makefile +0 -0
  32. {stemtrace-0.3.2 → stemtrace-0.3.4}/build_ui.py +0 -0
  33. {stemtrace-0.3.2 → stemtrace-0.3.4}/docker-compose.e2e.rabbitmq.yml +0 -0
  34. {stemtrace-0.3.2 → stemtrace-0.3.4}/docker-compose.e2e.yml +0 -0
  35. {stemtrace-0.3.2 → stemtrace-0.3.4}/docker-compose.rabbitmq.yml +0 -0
  36. {stemtrace-0.3.2 → stemtrace-0.3.4}/docker-compose.yml +0 -0
  37. {stemtrace-0.3.2 → stemtrace-0.3.4}/docs/screenshots/task_details.png +0 -0
  38. {stemtrace-0.3.2 → stemtrace-0.3.4}/docs/screenshots/unregistered.png +0 -0
  39. {stemtrace-0.3.2 → stemtrace-0.3.4}/docs/screenshots/workflow.png +0 -0
  40. {stemtrace-0.3.2 → stemtrace-0.3.4}/examples/celery_app.py +0 -0
  41. {stemtrace-0.3.2 → stemtrace-0.3.4}/examples/fastapi_integration.py +0 -0
  42. {stemtrace-0.3.2 → stemtrace-0.3.4}/examples/with_auth.py +0 -0
  43. {stemtrace-0.3.2 → stemtrace-0.3.4}/examples/with_login.py +0 -0
  44. {stemtrace-0.3.2 → stemtrace-0.3.4}/progress.md +0 -0
  45. {stemtrace-0.3.2 → stemtrace-0.3.4}/scripts/wait_for_http.py +0 -0
  46. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/core/__init__.py +0 -0
  47. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/core/events.py +0 -0
  48. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/core/exceptions.py +0 -0
  49. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/core/graph.py +0 -0
  50. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/core/ports.py +0 -0
  51. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/__init__.py +0 -0
  52. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/bootsteps.py +0 -0
  53. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/scrubbing.py +0 -0
  54. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/signals.py +0 -0
  55. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/transports/__init__.py +0 -0
  56. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/transports/memory.py +0 -0
  57. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/transports/rabbitmq.py +0 -0
  58. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/library/transports/redis.py +0 -0
  59. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/py.typed +0 -0
  60. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/__init__.py +0 -0
  61. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/__main__.py +0 -0
  62. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/api/__init__.py +0 -0
  63. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/api/schemas.py +0 -0
  64. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/consumer.py +0 -0
  65. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/__init__.py +0 -0
  66. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/auth.py +0 -0
  67. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/form_auth.py +0 -0
  68. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/store.py +0 -0
  69. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/__init__.py +0 -0
  70. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/biome.json +0 -0
  71. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/index.html +0 -0
  72. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/playwright.config.ts +0 -0
  73. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/postcss.config.js +0 -0
  74. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/api/client.ts +0 -0
  75. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/api/queries.ts +0 -0
  76. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +0 -0
  77. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/Filters.tsx +0 -0
  78. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +0 -0
  79. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskList.tsx +0 -0
  80. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +0 -0
  81. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +0 -0
  82. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +0 -0
  83. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/Tooltip.tsx +0 -0
  84. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +0 -0
  85. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +0 -0
  86. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/index.css +0 -0
  87. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/main.tsx +0 -0
  88. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/__root.tsx +0 -0
  89. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +0 -0
  90. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/graphs.tsx +0 -0
  91. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/index.tsx +0 -0
  92. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/registry.tsx +0 -0
  93. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +0 -0
  94. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/workers.tsx +0 -0
  95. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/utils/format.ts +0 -0
  96. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/vite-env.d.ts +0 -0
  97. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-api.ts +0 -0
  98. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-data.ts +0 -0
  99. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/graphs.spec.ts +0 -0
  100. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/registry.spec.ts +0 -0
  101. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/task-detail.spec.ts +0 -0
  102. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/tasks.spec.ts +0 -0
  103. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/workers.spec.ts +0 -0
  104. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tsconfig.json +0 -0
  105. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +0 -0
  106. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/vite.config.ts +0 -0
  107. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/static.py +0 -0
  108. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/ui/templates/login.html +0 -0
  109. {stemtrace-0.3.2 → stemtrace-0.3.4}/src/stemtrace/server/websocket.py +0 -0
  110. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/__init__.py +0 -0
  111. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/conftest.py +0 -0
  112. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/e2e/__init__.py +0 -0
  113. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/e2e/conftest.py +0 -0
  114. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/e2e/tasks.py +0 -0
  115. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/e2e/test_api_e2e.py +0 -0
  116. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/e2e/test_workers_e2e.py +0 -0
  117. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/integration/__init__.py +0 -0
  118. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/integration/test_embedded_static.py +0 -0
  119. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/integration/test_fastapi_integration.py +0 -0
  120. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/integration/test_rabbitmq_transport.py +0 -0
  121. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/integration/test_redis_transport.py +0 -0
  122. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/integration/test_static_ui.py +0 -0
  123. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/integration/test_workers_refresh.py +0 -0
  124. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/__init__.py +0 -0
  125. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_auth.py +0 -0
  126. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_bootsteps.py +0 -0
  127. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_config.py +0 -0
  128. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_consumer.py +0 -0
  129. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_events.py +0 -0
  130. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_examples.py +0 -0
  131. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_exceptions.py +0 -0
  132. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_form_auth.py +0 -0
  133. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_graph.py +0 -0
  134. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_scrubbing.py +0 -0
  135. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_signals.py +0 -0
  136. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_store.py +0 -0
  137. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_transports.py +0 -0
  138. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_ui_static_prefix.py +0 -0
  139. {stemtrace-0.3.2 → stemtrace-0.3.4}/tests/unit/test_websocket.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "0.3.2"
2
+ current_version = "0.3.4"
3
3
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
4
4
  serialize = ["{major}.{minor}.{patch}"]
5
5
  commit = true
@@ -152,7 +152,7 @@ jobs:
152
152
  run: uvx twine check dist/*
153
153
 
154
154
  - name: Upload dist artifacts
155
- uses: actions/upload-artifact@v6
155
+ uses: actions/upload-artifact@v7
156
156
  with:
157
157
  name: dist
158
158
  path: dist/
@@ -24,7 +24,7 @@ jobs:
24
24
  - uses: actions/checkout@v6
25
25
 
26
26
  - name: Set up Docker Buildx
27
- uses: docker/setup-buildx-action@v3
27
+ uses: docker/setup-buildx-action@v4
28
28
 
29
29
  - name: Select compose file and Celery env
30
30
  run: |
@@ -91,7 +91,7 @@ jobs:
91
91
  - uses: actions/checkout@v6
92
92
 
93
93
  - name: Set up Docker Buildx
94
- uses: docker/setup-buildx-action@v3
94
+ uses: docker/setup-buildx-action@v4
95
95
 
96
96
  - name: Select compose file and Celery env
97
97
  run: |
@@ -167,7 +167,7 @@ jobs:
167
167
  PLAYWRIGHT_BASE_URL: http://localhost:8000
168
168
 
169
169
  - name: Upload Playwright report
170
- uses: actions/upload-artifact@v6
170
+ uses: actions/upload-artifact@v7
171
171
  if: always()
172
172
  with:
173
173
  name: playwright-report-${{ matrix.broker }}
@@ -175,7 +175,7 @@ jobs:
175
175
  retention-days: 7
176
176
 
177
177
  - name: Upload test results
178
- uses: actions/upload-artifact@v6
178
+ uses: actions/upload-artifact@v7
179
179
  if: failure()
180
180
  with:
181
181
  name: playwright-test-results-${{ matrix.broker }}
@@ -74,7 +74,7 @@ jobs:
74
74
  run: twine check dist/*
75
75
 
76
76
  - name: Upload dist artifacts
77
- uses: actions/upload-artifact@v6
77
+ uses: actions/upload-artifact@v7
78
78
  with:
79
79
  name: dist
80
80
  path: dist/
@@ -87,7 +87,7 @@ jobs:
87
87
  id-token: write
88
88
  steps:
89
89
  - name: Download dist artifacts
90
- uses: actions/download-artifact@v7
90
+ uses: actions/download-artifact@v8
91
91
  with:
92
92
  name: dist
93
93
  path: dist/
@@ -104,8 +104,11 @@ jobs:
104
104
  steps:
105
105
  - uses: actions/checkout@v6
106
106
 
107
+ - name: Set up QEMU
108
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
109
+
107
110
  - name: Set up Docker Buildx
108
- uses: docker/setup-buildx-action@v3
111
+ uses: docker/setup-buildx-action@v4
109
112
 
110
113
  - name: Log in to GitHub Container Registry
111
114
  uses: docker/login-action@v3
@@ -116,7 +119,7 @@ jobs:
116
119
 
117
120
  - name: Extract metadata
118
121
  id: meta
119
- uses: docker/metadata-action@v5
122
+ uses: docker/metadata-action@v6
120
123
  with:
121
124
  images: ghcr.io/${{ github.repository }}
122
125
  tags: |
@@ -124,10 +127,11 @@ jobs:
124
127
  type=raw,value=latest
125
128
 
126
129
  - name: Build and push Docker image
127
- uses: docker/build-push-action@v6
130
+ uses: docker/build-push-action@v7
128
131
  with:
129
132
  context: .
130
133
  push: true
134
+ platforms: linux/amd64,linux/arm64
131
135
  tags: ${{ steps.meta.outputs.tags }}
132
136
  labels: ${{ steps.meta.outputs.labels }}
133
137
  cache-from: type=gha
@@ -142,7 +146,7 @@ jobs:
142
146
  - uses: actions/checkout@v6
143
147
 
144
148
  - name: Download dist artifacts
145
- uses: actions/download-artifact@v7
149
+ uses: actions/download-artifact@v8
146
150
  with:
147
151
  name: dist
148
152
  path: dist/
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.4] - 2026-06-25
11
+
12
+ ### Fixed
13
+ - WebSocket: deny unauthenticated connections during the handshake instead of accepting and then closing them. The accept-then-close path half-opened the connection, so when an unauthenticated client (e.g. a port scanner) aborted mid-handshake, uvicorn's legacy `websockets` implementation ran `close()` on a protocol whose `transfer_data_task` was never set, raising `AttributeError: 'WebSocketProtocol' object has no attribute 'transfer_data_task'`. Closing before `accept()` rejects the handshake (HTTP 403) without opening the protocol object.
14
+ - Form login: build the login form action and redirects as relative paths instead of absolute URLs, and derive the session cookie's `Secure` flag from `X-Forwarded-Proto`. Behind a TLS-terminating proxy that doesn't forward the scheme to the ASGI scope (e.g. uvicorn without `--forwarded-allow-ips`), absolute URLs were downgraded to `http://`, so the browser posted the login form over http, the proxy answered `301 -> https`, the POST body was dropped, and login silently failed (with a mixed-content warning). stemtrace now works behind such a proxy without requiring forwarded-headers configuration.
15
+
16
+ ## [0.3.3] - 2026-03-20
17
+
18
+ ### Added
19
+ - Node alias from arguments: derive graph node display name from task args/kwargs via `node_alias_from_arguments` config option (thanks @ohadmata for the idea — #46, #48)
20
+ - Docker: multi-architecture image builds (amd64 + arm64) (thanks @ohadmata — #51)
21
+
22
+ ### Fixed
23
+ - Graph: order task nodes before synthetic container nodes (GROUP/CHORD) for correct rendering (thanks @codyburrito — #52)
24
+
10
25
  ## [0.3.2] - 2026-02-28
11
26
 
12
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stemtrace
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: A lightweight Celery task flow visualizer
5
5
  Project-URL: Homepage, https://github.com/iansokolskyi/stemtrace
6
6
  Project-URL: Documentation, https://github.com/iansokolskyi/stemtrace#readme
@@ -56,7 +56,7 @@ Description-Content-Type: text/markdown
56
56
 
57
57
  **Zero-infrastructure Celery task flow visualizer**
58
58
 
59
- [![PyPI version](https://img.shields.io/badge/pypi-v0.3.2-darklime)](https://pypi.org/project/stemtrace)
59
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.3.4-darklime)](https://pypi.org/project/stemtrace)
60
60
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
61
61
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
62
62
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -250,6 +250,10 @@ stemtrace.init_worker(
250
250
  scrub_sensitive_data=True, # Scrub passwords, API keys, etc.
251
251
  additional_sensitive_keys=frozenset({"my_secret"}), # Add custom keys
252
252
  safe_keys=frozenset({"public_key"}), # Never scrub these keys
253
+
254
+ # UI display
255
+ node_alias_from_arguments="operator_type", # Use kwargs["operator_type"] as node name
256
+ # node_alias_from_arguments="0", # Or use args[0] (digit string = positional index)
253
257
  )
254
258
 
255
259
  # Introspection (after init)
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Zero-infrastructure Celery task flow visualizer**
4
4
 
5
- [![PyPI version](https://img.shields.io/badge/pypi-v0.3.2-darklime)](https://pypi.org/project/stemtrace)
5
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.3.4-darklime)](https://pypi.org/project/stemtrace)
6
6
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
7
7
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
8
8
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -196,6 +196,10 @@ stemtrace.init_worker(
196
196
  scrub_sensitive_data=True, # Scrub passwords, API keys, etc.
197
197
  additional_sensitive_keys=frozenset({"my_secret"}), # Add custom keys
198
198
  safe_keys=frozenset({"public_key"}), # Never scrub these keys
199
+
200
+ # UI display
201
+ node_alias_from_arguments="operator_type", # Use kwargs["operator_type"] as node name
202
+ # node_alias_from_arguments="0", # Or use args[0] (digit string = positional index)
199
203
  )
200
204
 
201
205
  # Introspection (after init)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stemtrace"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "A lightweight Celery task flow visualizer"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
43
43
  from celery import Celery
44
44
  from fastapi import FastAPI
45
45
 
46
- __version__ = "0.3.2"
46
+ __version__ = "0.3.4"
47
47
  __all__ = [
48
48
  "ConfigurationError",
49
49
  "StemtraceConfig",
@@ -93,6 +93,7 @@ def init_worker(
93
93
  additional_sensitive_keys: frozenset[str] | None = None,
94
94
  safe_keys: frozenset[str] | None = None,
95
95
  max_data_size: int = 10240,
96
+ node_alias_from_arguments: str | None = None,
96
97
  ) -> None:
97
98
  """Initialize stemtrace for Celery worker instrumentation.
98
99
 
@@ -107,6 +108,9 @@ def init_worker(
107
108
  additional_sensitive_keys: Extra keys to treat as sensitive.
108
109
  safe_keys: Keys to never scrub (overrides sensitive).
109
110
  max_data_size: Maximum size in bytes for serialized data (default: 10KB).
111
+ node_alias_from_arguments: Derive node display name from task arguments.
112
+ Pass a digit string to use args[index], a string key for kwargs[key],
113
+ or None to use the task name (default).
110
114
 
111
115
  Raises:
112
116
  ConfigurationError: If no broker URL can be determined.
@@ -130,6 +134,7 @@ def init_worker(
130
134
  additional_sensitive_keys=additional_sensitive_keys or frozenset(),
131
135
  safe_keys=safe_keys or frozenset(),
132
136
  max_data_size=max_data_size,
137
+ node_alias_from_arguments=node_alias_from_arguments,
133
138
  )
134
139
  set_config(config)
135
140
 
@@ -153,6 +158,7 @@ def init_app(
153
158
  login_password: str | None = None,
154
159
  login_secret: str | None = None,
155
160
  login_ttl_seconds: int = 86400,
161
+ node_alias_from_arguments: str | None = None,
156
162
  ) -> StemtraceExtension:
157
163
  """Initialize stemtrace as a FastAPI extension.
158
164
 
@@ -174,6 +180,8 @@ def init_app(
174
180
  STEMTRACE_LOGIN_SECRET env var. If still missing, a random secret is
175
181
  generated (sessions invalidated on app restart).
176
182
  login_ttl_seconds: Session TTL in seconds (default: 24 hours).
183
+ node_alias_from_arguments: Derive graph node display name from task arguments.
184
+ Digit string for args[index], string for kwargs[key], or None (default).
177
185
 
178
186
  Returns:
179
187
  The initialized StemtraceExtension instance.
@@ -230,6 +238,7 @@ def init_app(
230
238
  max_nodes=max_nodes,
231
239
  auth_dependency=auth_dependency,
232
240
  form_auth_config=form_auth_config,
241
+ node_alias_from_arguments=node_alias_from_arguments,
233
242
  )
234
243
  extension.init_app(fastapi_app, prefix=prefix)
235
244
 
@@ -16,6 +16,9 @@ class StemtraceConfig(BaseModel):
16
16
  scrub_sensitive_data: Whether to scrub sensitive keys.
17
17
  additional_sensitive_keys: Extra keys to treat as sensitive.
18
18
  safe_keys: Keys to never scrub (overrides sensitive).
19
+ node_alias_from_arguments: Derive graph node display name from task
20
+ arguments. Pass a string key to use kwargs[key], a digit string
21
+ to use args[index], or None to use the task name (default).
19
22
  """
20
23
 
21
24
  model_config = ConfigDict(frozen=True)
@@ -34,6 +37,9 @@ class StemtraceConfig(BaseModel):
34
37
  additional_sensitive_keys: frozenset[str] = Field(default_factory=frozenset)
35
38
  safe_keys: frozenset[str] = Field(default_factory=frozenset)
36
39
 
40
+ # UI display options
41
+ node_alias_from_arguments: str | None = None
42
+
37
43
 
38
44
  _config: StemtraceConfig | None = None
39
45
 
@@ -123,9 +123,40 @@ def _node_to_response(node: TaskNode) -> TaskNodeResponse:
123
123
  )
124
124
 
125
125
 
126
+ def _resolve_node_alias(node: TaskNode, key: str | None) -> str:
127
+ """Resolve display name for a graph node from task arguments.
128
+
129
+ When a key is configured, attempts to extract a value from the first
130
+ event's args (if key is a digit string) or kwargs (if key is a regular
131
+ string). Falls back to the node's registered task name.
132
+
133
+ Args:
134
+ node: Task node containing events with args/kwargs.
135
+ key: Digit string for args[index], string for kwargs[key],
136
+ or None to use the task name.
137
+
138
+ Returns:
139
+ Resolved display name for the node.
140
+ """
141
+ if key is None or not node.events:
142
+ return node.name
143
+
144
+ event = node.events[0]
145
+
146
+ if key.isdigit():
147
+ index = int(key)
148
+ if isinstance(event.args, list) and index < len(event.args):
149
+ return str(event.args[index])
150
+ elif isinstance(event.kwargs, dict) and key in event.kwargs:
151
+ return str(event.kwargs[key])
152
+
153
+ return node.name
154
+
155
+
126
156
  def _node_to_graph_response(
127
157
  node: TaskNode,
128
158
  all_nodes: dict[str, TaskNode] | None = None,
159
+ node_alias_key: str | None = None,
129
160
  ) -> GraphNodeResponse:
130
161
  """Convert TaskNode to graph response model.
131
162
 
@@ -134,6 +165,7 @@ def _node_to_graph_response(
134
165
  Args:
135
166
  node: Task node from graph store.
136
167
  all_nodes: All nodes dict for child lookup.
168
+ node_alias_key: Key to resolve display name from task arguments.
137
169
 
138
170
  Returns:
139
171
  GraphNodeResponse with timing from children.
@@ -159,15 +191,25 @@ def _node_to_graph_response(
159
191
  if first_seen and last_updated and first_seen != last_updated:
160
192
  duration_ms = int((last_updated - first_seen).total_seconds() * 1000)
161
193
 
194
+ # UI traversal is order-sensitive when callbacks can appear as both direct
195
+ # children and CHORD-linked nodes. Prefer TASK children first so callback
196
+ # branches render before synthetic containers.
197
+ children = node.children
198
+ if all_nodes is not None:
199
+ children = sorted(
200
+ node.children,
201
+ key=lambda c: getattr(all_nodes.get(c), "node_type", None) != "TASK",
202
+ )
203
+
162
204
  return GraphNodeResponse(
163
205
  task_id=node.task_id,
164
- name=node.name,
206
+ name=_resolve_node_alias(node, node_alias_key),
165
207
  state=node.state,
166
208
  node_type=node.node_type,
167
209
  group_id=node.group_id,
168
210
  chord_id=node.chord_id,
169
211
  parent_id=node.parent_id,
170
- children=node.children,
212
+ children=children,
171
213
  duration_ms=duration_ms,
172
214
  first_seen=first_seen,
173
215
  last_updated=last_updated,
@@ -289,6 +331,7 @@ def create_api_router(
289
331
  ws_manager: WebSocketManager | None = None,
290
332
  worker_registry: WorkerRegistry | None = None,
291
333
  broker_url: str | None = None,
334
+ node_alias_from_arguments: str | None = None,
292
335
  ) -> APIRouter:
293
336
  """Create REST API router with task and graph endpoints.
294
337
 
@@ -298,6 +341,8 @@ def create_api_router(
298
341
  ws_manager: Optional WebSocket manager.
299
342
  worker_registry: Optional worker registry for lifecycle tracking.
300
343
  broker_url: Optional Celery broker URL for on-demand inspection.
344
+ node_alias_from_arguments: Key to derive node display name from task
345
+ arguments. Digit string for args[index], string for kwargs[key].
301
346
 
302
347
  Returns:
303
348
  Configured API router.
@@ -562,7 +607,12 @@ def create_api_router(
562
607
  for child in store.get_children(root.task_id):
563
608
  all_nodes[child.task_id] = child
564
609
  return GraphListResponse(
565
- graphs=[_node_to_graph_response(r, all_nodes) for r in roots],
610
+ graphs=[
611
+ _node_to_graph_response(
612
+ r, all_nodes, node_alias_key=node_alias_from_arguments
613
+ )
614
+ for r in roots
615
+ ],
566
616
  total=total,
567
617
  limit=limit,
568
618
  offset=offset,
@@ -586,7 +636,10 @@ def create_api_router(
586
636
  return GraphResponse(
587
637
  root_id=root_id,
588
638
  nodes={
589
- tid: _node_to_graph_response(n, all_nodes) for tid, n in graph.items()
639
+ tid: _node_to_graph_response(
640
+ n, all_nodes, node_alias_key=node_alias_from_arguments
641
+ )
642
+ for tid, n in graph.items()
590
643
  },
591
644
  )
592
645
 
@@ -33,8 +33,12 @@ def create_websocket_router(
33
33
  secret=form_auth_config.secret,
34
34
  expected_username=form_auth_config.username,
35
35
  ):
36
- # Close without registering the connection.
37
- await websocket.accept()
36
+ # Deny the handshake without completing it. Accepting first and
37
+ # then closing half-opens the connection; if the unauthenticated
38
+ # client aborts during that dance, uvicorn's legacy websockets
39
+ # impl runs close() on a protocol whose transfer_data_task was
40
+ # never set and raises AttributeError. Closing before accept lets
41
+ # Starlette reject the handshake (HTTP 403) without opening it.
38
42
  await websocket.close(code=1008)
39
43
  return
40
44
 
@@ -67,6 +67,7 @@ class StemtraceExtension:
67
67
  max_nodes: int = 10000,
68
68
  auth_dependency: Any = None,
69
69
  form_auth_config: FormAuthConfig | None = None,
70
+ node_alias_from_arguments: str | None = None,
70
71
  ) -> None:
71
72
  """Initialize extension with broker and transport configuration.
72
73
 
@@ -81,6 +82,8 @@ class StemtraceExtension:
81
82
  max_nodes: Maximum number of nodes to keep in memory.
82
83
  auth_dependency: Optional FastAPI dependency applied to all routes for authentication.
83
84
  form_auth_config: Optional cookie-session configuration used to protect WebSocket.
85
+ node_alias_from_arguments: Key to derive graph node display name from task
86
+ arguments. Digit string for args[index], string for kwargs[key].
84
87
  """
85
88
  self._broker_url = broker_url
86
89
  self._transport_url = transport_url or broker_url
@@ -103,6 +106,8 @@ class StemtraceExtension:
103
106
  worker_registry=self._worker_registry,
104
107
  )
105
108
 
109
+ self._node_alias_from_arguments = node_alias_from_arguments
110
+
106
111
  self._store.add_listener(self._ws_manager.queue_event)
107
112
 
108
113
  @staticmethod
@@ -141,6 +146,7 @@ class StemtraceExtension:
141
146
  broker_url=self._broker_url,
142
147
  auth_dependency=self._auth_dependency,
143
148
  form_auth_config=self._form_auth_config,
149
+ node_alias_from_arguments=self._node_alias_from_arguments,
144
150
  )
145
151
 
146
152
  if self._serve_ui:
@@ -40,6 +40,20 @@ def _safe_next(next_path: str | None, *, default: str) -> str:
40
40
  return next_path
41
41
 
42
42
 
43
+ def _request_is_https(request: Request) -> bool:
44
+ """Best-effort HTTPS detection that survives a TLS-terminating proxy.
45
+
46
+ Falls back to the ``X-Forwarded-Proto`` header when the ASGI scheme is
47
+ ``http`` because proxy headers weren't applied to the scope (e.g. uvicorn
48
+ without ``--forwarded-allow-ips``). The first value of a comma-separated
49
+ list is the original client scheme.
50
+ """
51
+ if request.url.scheme == "https":
52
+ return True
53
+ forwarded = request.headers.get("x-forwarded-proto", "")
54
+ return forwarded.split(",")[0].strip().lower() == "https"
55
+
56
+
43
57
  def _login_page_html(*, action_path: str, error: str | None, next_path: str) -> str:
44
58
  """Render the login page HTML from a template."""
45
59
  escaped_title = html.escape(_DEFAULT_TITLE, quote=True)
@@ -92,7 +106,10 @@ def create_login_router(
92
106
  next_path = _safe_next(
93
107
  request.query_params.get("next"), default=default_next_path
94
108
  )
95
- action_path = str(request.url_for("stemtrace_login_submit"))
109
+ # Relative path (not an absolute URL): the browser submits using the
110
+ # page's own scheme/host, so a TLS-terminating proxy that doesn't
111
+ # forward the scheme can't downgrade the form action to http.
112
+ action_path = request.url_for("stemtrace_login_submit").path
96
113
  rendered_html = _login_page_html(
97
114
  action_path=action_path, error=error, next_path=next_path
98
115
  )
@@ -125,7 +142,7 @@ def create_login_router(
125
142
  qs = {"error": "Invalid username or password"}
126
143
  if next_param:
127
144
  qs["next"] = next_param
128
- login_url = str(request.url_for("stemtrace_login_page"))
145
+ login_url = request.url_for("stemtrace_login_page").path
129
146
  return RedirectResponse(
130
147
  url=login_url + "?" + urllib.parse.urlencode(qs),
131
148
  status_code=303,
@@ -141,7 +158,7 @@ def create_login_router(
141
158
  value=config.create_session_cookie_value(),
142
159
  httponly=True,
143
160
  samesite="lax",
144
- secure=request.url.scheme == "https",
161
+ secure=_request_is_https(request),
145
162
  path=config.cookie_path,
146
163
  )
147
164
  return response
@@ -149,7 +166,7 @@ def create_login_router(
149
166
  @router.post("/logout", include_in_schema=False, name="stemtrace_logout")
150
167
  async def logout(request: Request) -> RedirectResponse:
151
168
  """Clear the session cookie and redirect to login."""
152
- login_url = str(request.url_for("stemtrace_login_page"))
169
+ login_url = request.url_for("stemtrace_login_page").path
153
170
  response = RedirectResponse(url=login_url, status_code=303)
154
171
  response.delete_cookie(key=config.cookie_name, path=config.cookie_path)
155
172
  return response
@@ -24,6 +24,7 @@ def create_router(
24
24
  broker_url: str | None = None,
25
25
  auth_dependency: Any = None,
26
26
  form_auth_config: FormAuthConfig | None = None,
27
+ node_alias_from_arguments: str | None = None,
27
28
  ) -> APIRouter:
28
29
  """Create API router. For embedded consumer, use StemtraceExtension."""
29
30
  if store is None:
@@ -40,6 +41,7 @@ def create_router(
40
41
  ws_manager,
41
42
  worker_registry,
42
43
  broker_url=broker_url,
44
+ node_alias_from_arguments=node_alias_from_arguments,
43
45
  )
44
46
  ws_router = create_websocket_router(ws_manager, form_auth_config=form_auth_config)
45
47