neutronium 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neutronium-0.1.0/LICENSE +21 -0
- neutronium-0.1.0/PKG-INFO +77 -0
- neutronium-0.1.0/README.md +47 -0
- neutronium-0.1.0/neutronium/__init__.py +1 -0
- neutronium-0.1.0/neutronium/logging/__init__.py +0 -0
- neutronium-0.1.0/neutronium/logging/filters.py +55 -0
- neutronium-0.1.0/neutronium/logging/formatters.py +129 -0
- neutronium-0.1.0/neutronium/telemetry/__init__.py +19 -0
- neutronium-0.1.0/neutronium/telemetry/context.py +32 -0
- neutronium-0.1.0/neutronium/telemetry/facts.py +37 -0
- neutronium-0.1.0/neutronium/telemetry/sinks/__init__.py +4 -0
- neutronium-0.1.0/neutronium/telemetry/sinks/access_log.py +40 -0
- neutronium-0.1.0/neutronium/telemetry/sinks/base.py +23 -0
- neutronium-0.1.0/neutronium/threads/__init__.py +0 -0
- neutronium-0.1.0/neutronium/threads/thread_simple.py +72 -0
- neutronium-0.1.0/neutronium/utils/__init__.py +0 -0
- neutronium-0.1.0/neutronium/utils/aws.py +60 -0
- neutronium-0.1.0/neutronium/utils/email.py +23 -0
- neutronium-0.1.0/neutronium/utils/hash.py +52 -0
- neutronium-0.1.0/neutronium/utils/iterable.py +213 -0
- neutronium-0.1.0/neutronium/utils/json_patch.py +77 -0
- neutronium-0.1.0/neutronium/utils/memory.py +72 -0
- neutronium-0.1.0/neutronium/utils/params.py +70 -0
- neutronium-0.1.0/neutronium/utils/print.py +50 -0
- neutronium-0.1.0/neutronium/utils/profiling.py +79 -0
- neutronium-0.1.0/neutronium/utils/schema.py +32 -0
- neutronium-0.1.0/neutronium/utils/ssm.py +53 -0
- neutronium-0.1.0/neutronium/utils/template_context.py +657 -0
- neutronium-0.1.0/neutronium/utils/text.py +830 -0
- neutronium-0.1.0/neutronium/utils/time.py +121 -0
- neutronium-0.1.0/neutronium/utils/url_credentials.py +43 -0
- neutronium-0.1.0/neutronium/utils/xpath.py +52 -0
- neutronium-0.1.0/neutronium.egg-info/PKG-INFO +77 -0
- neutronium-0.1.0/neutronium.egg-info/SOURCES.txt +41 -0
- neutronium-0.1.0/neutronium.egg-info/dependency_links.txt +1 -0
- neutronium-0.1.0/neutronium.egg-info/requires.txt +25 -0
- neutronium-0.1.0/neutronium.egg-info/top_level.txt +1 -0
- neutronium-0.1.0/pyproject.toml +43 -0
- neutronium-0.1.0/setup.cfg +4 -0
- neutronium-0.1.0/tests/test_email.py +18 -0
- neutronium-0.1.0/tests/test_template_context.py +621 -0
- neutronium-0.1.0/tests/test_text.py +30 -0
- neutronium-0.1.0/tests/test_xpath.py +38 -0
neutronium-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gaussian Holdings, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: neutronium
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic Python utilities: text, time, iterables, structured logging, telemetry primitives, and more.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/gaussian/neutronium
|
|
7
|
+
Project-URL: Issues, https://github.com/gaussian/neutronium/issues
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: text
|
|
12
|
+
Requires-Dist: inflection; extra == "text"
|
|
13
|
+
Provides-Extra: time
|
|
14
|
+
Requires-Dist: python-dateutil; extra == "time"
|
|
15
|
+
Provides-Extra: memory
|
|
16
|
+
Requires-Dist: psutil; extra == "memory"
|
|
17
|
+
Requires-Dist: pympler; extra == "memory"
|
|
18
|
+
Requires-Dist: objgraph; extra == "memory"
|
|
19
|
+
Provides-Extra: aws
|
|
20
|
+
Requires-Dist: boto3>=1.36; extra == "aws"
|
|
21
|
+
Requires-Dist: requests>=2.23; extra == "aws"
|
|
22
|
+
Requires-Dist: ec2-metadata; extra == "aws"
|
|
23
|
+
Provides-Extra: xpath
|
|
24
|
+
Requires-Dist: lxml; extra == "xpath"
|
|
25
|
+
Provides-Extra: otel
|
|
26
|
+
Requires-Dist: opentelemetry-api; extra == "otel"
|
|
27
|
+
Provides-Extra: profiling
|
|
28
|
+
Requires-Dist: line_profiler; extra == "profiling"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# neutronium
|
|
32
|
+
|
|
33
|
+
Framework-agnostic Python utilities, extracted from Gaussian's internal
|
|
34
|
+
infrastructure. No Django, no web framework, no cloud SDK in the base install —
|
|
35
|
+
everything third-party is an optional extra.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install neutronium # base: zero dependencies
|
|
39
|
+
pip install neutronium[text] # inflection-backed pluralize/singularize
|
|
40
|
+
pip install neutronium[xpath] # lxml-backed HTML/XPath helpers
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What's inside
|
|
44
|
+
|
|
45
|
+
| Module | What it gives you |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `neutronium.utils.text` | Text normalization, slugs, tokenization, pluralize/singularize |
|
|
48
|
+
| `neutronium.utils.time` | Timezone-aware datetime helpers |
|
|
49
|
+
| `neutronium.utils.iterable` | Chunking, grouping, flattening, dict utilities |
|
|
50
|
+
| `neutronium.utils.template_context` | Pure template-variable extraction and context building |
|
|
51
|
+
| `neutronium.utils.json_patch` | RFC-6901 JSON pointer / merge-patch primitives |
|
|
52
|
+
| `neutronium.utils.hash` | `blake2b` content hashing helpers |
|
|
53
|
+
| `neutronium.utils.schema` | JSON-schema → default-dict |
|
|
54
|
+
| `neutronium.utils.xpath` | lxml HTML parsing + link extraction (`[xpath]`) |
|
|
55
|
+
| `neutronium.utils.email` | Canonical email normalization |
|
|
56
|
+
| `neutronium.utils.{aws,ssm}` | EC2/ECS instance metadata + SSM parameter fetch (`[aws]`) |
|
|
57
|
+
| `neutronium.utils.{print,params,profiling,memory,url_credentials}` | Assorted small utilities |
|
|
58
|
+
| `neutronium.threads.thread_simple` | Minimal threading helpers |
|
|
59
|
+
| `neutronium.telemetry` | Request-context vars, `RequestFacts`, pluggable sinks |
|
|
60
|
+
| `neutronium.logging` | JSON / dev-console log formatters and context filters |
|
|
61
|
+
|
|
62
|
+
## Optional extras
|
|
63
|
+
|
|
64
|
+
`text`, `time`, `aws`, `memory`, `xpath`, `otel`, `profiling` — install only what you use.
|
|
65
|
+
|
|
66
|
+
- `aws` → `boto3`, `requests`, `ec2-metadata` (for `neutronium.utils.aws` / `ssm`)
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
uv sync --group dev
|
|
72
|
+
uv run pytest
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT © Gaussian Holdings, LLC
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# neutronium
|
|
2
|
+
|
|
3
|
+
Framework-agnostic Python utilities, extracted from Gaussian's internal
|
|
4
|
+
infrastructure. No Django, no web framework, no cloud SDK in the base install —
|
|
5
|
+
everything third-party is an optional extra.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install neutronium # base: zero dependencies
|
|
9
|
+
pip install neutronium[text] # inflection-backed pluralize/singularize
|
|
10
|
+
pip install neutronium[xpath] # lxml-backed HTML/XPath helpers
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What's inside
|
|
14
|
+
|
|
15
|
+
| Module | What it gives you |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `neutronium.utils.text` | Text normalization, slugs, tokenization, pluralize/singularize |
|
|
18
|
+
| `neutronium.utils.time` | Timezone-aware datetime helpers |
|
|
19
|
+
| `neutronium.utils.iterable` | Chunking, grouping, flattening, dict utilities |
|
|
20
|
+
| `neutronium.utils.template_context` | Pure template-variable extraction and context building |
|
|
21
|
+
| `neutronium.utils.json_patch` | RFC-6901 JSON pointer / merge-patch primitives |
|
|
22
|
+
| `neutronium.utils.hash` | `blake2b` content hashing helpers |
|
|
23
|
+
| `neutronium.utils.schema` | JSON-schema → default-dict |
|
|
24
|
+
| `neutronium.utils.xpath` | lxml HTML parsing + link extraction (`[xpath]`) |
|
|
25
|
+
| `neutronium.utils.email` | Canonical email normalization |
|
|
26
|
+
| `neutronium.utils.{aws,ssm}` | EC2/ECS instance metadata + SSM parameter fetch (`[aws]`) |
|
|
27
|
+
| `neutronium.utils.{print,params,profiling,memory,url_credentials}` | Assorted small utilities |
|
|
28
|
+
| `neutronium.threads.thread_simple` | Minimal threading helpers |
|
|
29
|
+
| `neutronium.telemetry` | Request-context vars, `RequestFacts`, pluggable sinks |
|
|
30
|
+
| `neutronium.logging` | JSON / dev-console log formatters and context filters |
|
|
31
|
+
|
|
32
|
+
## Optional extras
|
|
33
|
+
|
|
34
|
+
`text`, `time`, `aws`, `memory`, `xpath`, `otel`, `profiling` — install only what you use.
|
|
35
|
+
|
|
36
|
+
- `aws` → `boto3`, `requests`, `ec2-metadata` (for `neutronium.utils.aws` / `ssm`)
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
uv sync --group dev
|
|
42
|
+
uv run pytest
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT © Gaussian Holdings, LLC
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from opentelemetry import trace
|
|
5
|
+
except ImportError:
|
|
6
|
+
trace = None # OpenTelemetry not installed
|
|
7
|
+
|
|
8
|
+
from neutronium.telemetry.context import (
|
|
9
|
+
client_ip_var,
|
|
10
|
+
enterprise_id_var,
|
|
11
|
+
request_id_var,
|
|
12
|
+
user_id_var,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _trace_id_hex(ctx) -> str:
|
|
17
|
+
"""Convert an OTel trace ID integer to hex string."""
|
|
18
|
+
return format(ctx.trace_id, "032x")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _span_id_hex(ctx) -> str:
|
|
22
|
+
"""Convert an OTel span ID integer to hex string."""
|
|
23
|
+
return format(ctx.span_id, "016x")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ContextFilter(logging.Filter):
|
|
27
|
+
"""
|
|
28
|
+
Logging filter that injects request context and OpenTelemetry/X-Ray IDs into log records.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
32
|
+
# App context from contextvars
|
|
33
|
+
record.request_id = request_id_var.get()
|
|
34
|
+
record.user_id = user_id_var.get()
|
|
35
|
+
record.client_ip = client_ip_var.get()
|
|
36
|
+
record.enterprise_id = enterprise_id_var.get()
|
|
37
|
+
|
|
38
|
+
# OTel / X-Ray context (trace_id, span_id)
|
|
39
|
+
if trace:
|
|
40
|
+
ctx = trace.get_current_span().get_span_context()
|
|
41
|
+
if ctx.is_valid:
|
|
42
|
+
record.trace_id = _trace_id_hex(ctx)
|
|
43
|
+
record.span_id = _span_id_hex(ctx)
|
|
44
|
+
record.trace_sampled = bool(ctx.trace_flags.sampled)
|
|
45
|
+
else:
|
|
46
|
+
record.trace_id = None
|
|
47
|
+
record.span_id = None
|
|
48
|
+
record.trace_sampled = None
|
|
49
|
+
else:
|
|
50
|
+
# OpenTelemetry not installed, skip trace context
|
|
51
|
+
record.trace_id = None
|
|
52
|
+
record.span_id = None
|
|
53
|
+
record.trace_sampled = None
|
|
54
|
+
|
|
55
|
+
return True
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _json_default(obj):
|
|
11
|
+
"""Handle JSON serialization of non-standard types."""
|
|
12
|
+
if isinstance(obj, UUID):
|
|
13
|
+
return str(obj)
|
|
14
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonFormatter(logging.Formatter):
|
|
18
|
+
"""JSON formatter with ordered fields and null value omission."""
|
|
19
|
+
|
|
20
|
+
# Maps record attribute names to JSON output field names
|
|
21
|
+
CONTEXT_FIELDS = {
|
|
22
|
+
"request_id": "request_id",
|
|
23
|
+
"client_ip": "client_ip",
|
|
24
|
+
"user_id": "user.id",
|
|
25
|
+
"enterprise_id": "enterprise.id",
|
|
26
|
+
"trace_id": "trace_id",
|
|
27
|
+
"span_id": "span_id",
|
|
28
|
+
"trace_sampled": "trace.sampled",
|
|
29
|
+
# HTTP request fields from AccessLogMiddleware
|
|
30
|
+
"http.method": "http.method",
|
|
31
|
+
"http.path": "http.path",
|
|
32
|
+
"http.route": "http.route",
|
|
33
|
+
"http.status_code": "http.status_code",
|
|
34
|
+
"http.user_agent": "http.user_agent",
|
|
35
|
+
"duration_ms": "duration_ms",
|
|
36
|
+
"user.is_privileged": "user.is_privileged",
|
|
37
|
+
"django.view": "django.view",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
41
|
+
# Build payload with explicit field ordering
|
|
42
|
+
payload = {
|
|
43
|
+
"timestamp": datetime.fromtimestamp(
|
|
44
|
+
record.created, tz=timezone.utc
|
|
45
|
+
).isoformat(),
|
|
46
|
+
"level": record.levelname.lower(),
|
|
47
|
+
"message": record.getMessage(),
|
|
48
|
+
"logger": record.name,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if SERVICE_NAME:
|
|
52
|
+
payload["service.name"] = SERVICE_NAME
|
|
53
|
+
|
|
54
|
+
# Add context fields (injected by ContextFilter)
|
|
55
|
+
for attr, output_name in self.CONTEXT_FIELDS.items():
|
|
56
|
+
value = getattr(record, attr, None)
|
|
57
|
+
if value is not None:
|
|
58
|
+
payload[output_name] = value
|
|
59
|
+
|
|
60
|
+
# Include structured exception info if present
|
|
61
|
+
if record.exc_info:
|
|
62
|
+
exc_type, exc_value, _ = record.exc_info
|
|
63
|
+
if exc_type is not None:
|
|
64
|
+
payload["error.type"] = exc_type.__name__
|
|
65
|
+
if exc_value is not None:
|
|
66
|
+
payload["error.message"] = str(exc_value)
|
|
67
|
+
payload["error.stack_trace"] = self.formatException(record.exc_info)
|
|
68
|
+
|
|
69
|
+
# Include stack_info if requested (e.g., logger.info("msg", stack_info=True))
|
|
70
|
+
if record.stack_info:
|
|
71
|
+
payload["stack_info"] = self.formatStack(record.stack_info)
|
|
72
|
+
|
|
73
|
+
return json.dumps(payload, default=_json_default)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DevConsoleFormatter(logging.Formatter):
|
|
77
|
+
"""Human-readable formatter for local development."""
|
|
78
|
+
|
|
79
|
+
# Standard LogRecord attributes to exclude when looking for extra fields
|
|
80
|
+
_STANDARD_ATTRS = frozenset(
|
|
81
|
+
{
|
|
82
|
+
"name",
|
|
83
|
+
"msg",
|
|
84
|
+
"args",
|
|
85
|
+
"created",
|
|
86
|
+
"filename",
|
|
87
|
+
"funcName",
|
|
88
|
+
"levelname",
|
|
89
|
+
"levelno",
|
|
90
|
+
"lineno",
|
|
91
|
+
"module",
|
|
92
|
+
"msecs",
|
|
93
|
+
"pathname",
|
|
94
|
+
"process",
|
|
95
|
+
"processName",
|
|
96
|
+
"thread",
|
|
97
|
+
"threadName",
|
|
98
|
+
"exc_info",
|
|
99
|
+
"exc_text",
|
|
100
|
+
"stack_info",
|
|
101
|
+
"message",
|
|
102
|
+
"relativeCreated",
|
|
103
|
+
"taskName",
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
108
|
+
base = f"[{record.levelname}] {record.getMessage()} ({record.pathname}:{record.lineno})"
|
|
109
|
+
|
|
110
|
+
# Collect extra fields (anything not in standard LogRecord attributes)
|
|
111
|
+
extras = {
|
|
112
|
+
k: v
|
|
113
|
+
for k, v in record.__dict__.items()
|
|
114
|
+
if k not in self._STANDARD_ATTRS and not k.startswith("_")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if extras:
|
|
118
|
+
extra_str = " ".join(f"{k}={v!r}" for k, v in extras.items())
|
|
119
|
+
base = f"{base} | {extra_str}"
|
|
120
|
+
|
|
121
|
+
# Include exception info if present
|
|
122
|
+
if record.exc_info:
|
|
123
|
+
base = f"{base}\n{self.formatException(record.exc_info)}"
|
|
124
|
+
|
|
125
|
+
# Include stack_info if requested (e.g., logger.info("msg", stack_info=True))
|
|
126
|
+
if record.stack_info:
|
|
127
|
+
base = f"{base}\n{self.formatStack(record.stack_info)}"
|
|
128
|
+
|
|
129
|
+
return base
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .context import (
|
|
2
|
+
clear_request_context,
|
|
3
|
+
client_ip_var,
|
|
4
|
+
enterprise_id_var,
|
|
5
|
+
request_id_var,
|
|
6
|
+
set_request_context,
|
|
7
|
+
user_id_var,
|
|
8
|
+
)
|
|
9
|
+
from .facts import RequestFacts
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"request_id_var",
|
|
13
|
+
"user_id_var",
|
|
14
|
+
"client_ip_var",
|
|
15
|
+
"enterprise_id_var",
|
|
16
|
+
"set_request_context",
|
|
17
|
+
"clear_request_context",
|
|
18
|
+
"RequestFacts",
|
|
19
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
|
|
3
|
+
request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
4
|
+
user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
|
|
5
|
+
client_ip_var: ContextVar[str | None] = ContextVar("client_ip", default=None)
|
|
6
|
+
enterprise_id_var: ContextVar[str | None] = ContextVar("enterprise_id", default=None)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def set_request_context(
|
|
10
|
+
*,
|
|
11
|
+
request_id: str | None = None,
|
|
12
|
+
user_id: str | None = None,
|
|
13
|
+
client_ip: str | None = None,
|
|
14
|
+
enterprise_id: str | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Set request context variables for logging."""
|
|
17
|
+
if request_id is not None:
|
|
18
|
+
request_id_var.set(str(request_id))
|
|
19
|
+
if user_id is not None:
|
|
20
|
+
user_id_var.set(str(user_id))
|
|
21
|
+
if client_ip is not None:
|
|
22
|
+
client_ip_var.set(str(client_ip))
|
|
23
|
+
if enterprise_id is not None:
|
|
24
|
+
enterprise_id_var.set(str(enterprise_id))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clear_request_context() -> None:
|
|
28
|
+
"""Clear all request context variables."""
|
|
29
|
+
request_id_var.set(None)
|
|
30
|
+
user_id_var.set(None)
|
|
31
|
+
client_ip_var.set(None)
|
|
32
|
+
enterprise_id_var.set(None)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class RequestFacts:
|
|
6
|
+
"""
|
|
7
|
+
Immutable container for request telemetry data.
|
|
8
|
+
Collected by RequestTelemetryMiddleware and passed to all configured sinks.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Request identification
|
|
12
|
+
request_id: str | None = None
|
|
13
|
+
|
|
14
|
+
# Enterprise context (from X-ENTERPRISE-ID header)
|
|
15
|
+
enterprise_id: str | None = None
|
|
16
|
+
|
|
17
|
+
# User context
|
|
18
|
+
user_id: int | None = None
|
|
19
|
+
user_is_authenticated: bool = False
|
|
20
|
+
user_is_staff: bool = False
|
|
21
|
+
|
|
22
|
+
# HTTP request info
|
|
23
|
+
method: str = ""
|
|
24
|
+
host: str = ""
|
|
25
|
+
path: str = ""
|
|
26
|
+
route: str | None = None
|
|
27
|
+
view_name: str | None = None
|
|
28
|
+
|
|
29
|
+
# Response info
|
|
30
|
+
status_code: int | None = None
|
|
31
|
+
|
|
32
|
+
# Timing
|
|
33
|
+
duration_ms: int | None = None
|
|
34
|
+
|
|
35
|
+
# Client info
|
|
36
|
+
client_ip: str | None = None
|
|
37
|
+
user_agent: str = ""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from neutronium.telemetry.facts import RequestFacts
|
|
4
|
+
|
|
5
|
+
audit_logger = logging.getLogger("app.audit.request")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccessLogSink:
|
|
9
|
+
"""
|
|
10
|
+
Sink that logs authenticated HTTP requests to the audit logger.
|
|
11
|
+
|
|
12
|
+
Replicates the behavior of the legacy AccessLogMiddleware:
|
|
13
|
+
- Only logs authenticated requests
|
|
14
|
+
- Uses the same log message and extra fields
|
|
15
|
+
- Writes to 'app.audit.request' logger
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def emit(self, facts: RequestFacts) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Log authenticated request to the audit logger.
|
|
21
|
+
|
|
22
|
+
Only logs if the request was from an authenticated user,
|
|
23
|
+
matching the behavior of the original AccessLogMiddleware.
|
|
24
|
+
"""
|
|
25
|
+
if not facts.user_is_authenticated:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
audit_logger.info(
|
|
29
|
+
"Authenticated HTTP request",
|
|
30
|
+
extra={
|
|
31
|
+
"http.method": facts.method,
|
|
32
|
+
"http.path": facts.path,
|
|
33
|
+
"http.route": facts.route,
|
|
34
|
+
"http.status_code": facts.status_code,
|
|
35
|
+
"http.user_agent": facts.user_agent[:512] if facts.user_agent else "",
|
|
36
|
+
"duration_ms": facts.duration_ms,
|
|
37
|
+
"user.is_privileged": facts.user_is_staff,
|
|
38
|
+
"django.view": facts.view_name,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from neutronium.telemetry.facts import RequestFacts
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TelemetrySink(Protocol):
|
|
7
|
+
"""
|
|
8
|
+
Protocol defining the interface for request telemetry sinks.
|
|
9
|
+
|
|
10
|
+
Sinks receive RequestFacts after each request and can process them
|
|
11
|
+
in various ways (logging, analytics, metrics, etc.).
|
|
12
|
+
|
|
13
|
+
Each sink should be independent and not know about other sinks.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def emit(self, facts: RequestFacts) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Process the request telemetry data.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
facts: Immutable container with all request telemetry data.
|
|
22
|
+
"""
|
|
23
|
+
...
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SimpleThread(threading.Thread):
|
|
5
|
+
result = None
|
|
6
|
+
|
|
7
|
+
def __init__(self, func, run_sync=False, *args, **kwargs):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.kwargs = kwargs
|
|
10
|
+
# for kwarg in kwargs.items():
|
|
11
|
+
# setattr(self, kwarg[0], kwarg[1])
|
|
12
|
+
self.func = func
|
|
13
|
+
self.run_sync = run_sync
|
|
14
|
+
self.exception = None
|
|
15
|
+
|
|
16
|
+
def start(self):
|
|
17
|
+
# Allow for override to run synchronously...
|
|
18
|
+
if self.run_sync:
|
|
19
|
+
self.run()
|
|
20
|
+
else:
|
|
21
|
+
super().start()
|
|
22
|
+
|
|
23
|
+
def run(self):
|
|
24
|
+
try:
|
|
25
|
+
self.result = self.func(**self.kwargs)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
self.exception = e
|
|
28
|
+
|
|
29
|
+
def get_result(self):
|
|
30
|
+
if not self.run_sync:
|
|
31
|
+
self.join()
|
|
32
|
+
return self.result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SimpleThreadManager:
|
|
36
|
+
|
|
37
|
+
def __init__(self, pass_through_exceptions=False):
|
|
38
|
+
self.threads = []
|
|
39
|
+
self.results_list = []
|
|
40
|
+
self.results_total = 0
|
|
41
|
+
self.pass_through_exceptions = pass_through_exceptions
|
|
42
|
+
|
|
43
|
+
def start_thread(self, simple_thread):
|
|
44
|
+
"""
|
|
45
|
+
Add thread to the threads list and start it running.
|
|
46
|
+
:param simple_thread:
|
|
47
|
+
:type: SimpleThread
|
|
48
|
+
:return:
|
|
49
|
+
"""
|
|
50
|
+
simple_thread.start()
|
|
51
|
+
self.threads.append(simple_thread)
|
|
52
|
+
|
|
53
|
+
def wait_return_results_as_list(self):
|
|
54
|
+
for thread in self.threads:
|
|
55
|
+
result = thread.get_result()
|
|
56
|
+
if thread.exception and self.pass_through_exceptions:
|
|
57
|
+
raise thread.exception
|
|
58
|
+
if result is not None:
|
|
59
|
+
if isinstance(result, (list, set)):
|
|
60
|
+
self.results_list += result
|
|
61
|
+
else:
|
|
62
|
+
self.results_list.append(result)
|
|
63
|
+
self.threads = []
|
|
64
|
+
return self.results_list
|
|
65
|
+
|
|
66
|
+
def wait_return_results_as_total(self):
|
|
67
|
+
for thread in self.threads:
|
|
68
|
+
result = thread.get_result()
|
|
69
|
+
if result:
|
|
70
|
+
self.results_total += result
|
|
71
|
+
self.threads = []
|
|
72
|
+
return self.results_total
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
def get_instance_metadata():
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class InstanceMetadata:
|
|
7
|
+
private_ipv4: str | None = None
|
|
8
|
+
region: str | None = None
|
|
9
|
+
|
|
10
|
+
env_region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
|
|
11
|
+
|
|
12
|
+
ecs_metadata_uri = os.environ.get("ECS_CONTAINER_METADATA_URI_V4")
|
|
13
|
+
if ecs_metadata_uri:
|
|
14
|
+
try:
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
r = requests.get(f"{ecs_metadata_uri}/task", timeout=2)
|
|
18
|
+
r.raise_for_status()
|
|
19
|
+
task = r.json()
|
|
20
|
+
|
|
21
|
+
private_ip = None
|
|
22
|
+
for c in task.get("Containers", []):
|
|
23
|
+
for n in c.get("Networks", []):
|
|
24
|
+
ipv4s = n.get("IPv4Addresses") or []
|
|
25
|
+
if ipv4s:
|
|
26
|
+
private_ip = ipv4s[0]
|
|
27
|
+
break
|
|
28
|
+
if private_ip:
|
|
29
|
+
break
|
|
30
|
+
|
|
31
|
+
region = env_region
|
|
32
|
+
if not region:
|
|
33
|
+
task_arn = task.get("TaskARN") or ""
|
|
34
|
+
parts = task_arn.split(":")
|
|
35
|
+
if len(parts) > 2:
|
|
36
|
+
region = parts[2] # arn:aws:ecs:REGION:...
|
|
37
|
+
|
|
38
|
+
return InstanceMetadata(private_ipv4=private_ip, region=region)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from ec2_metadata import ec2_metadata
|
|
44
|
+
|
|
45
|
+
rel = os.environ.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
|
|
46
|
+
if rel:
|
|
47
|
+
ec2_metadata.SERVICE_URL = f"169.254.170.2{rel}"
|
|
48
|
+
ec2_metadata.DYNAMIC_URL = ec2_metadata.SERVICE_URL + "dynamic/"
|
|
49
|
+
ec2_metadata.METADATA_URL = ec2_metadata.SERVICE_URL + "meta-data/"
|
|
50
|
+
ec2_metadata.USERDATA_URL = ec2_metadata.SERVICE_URL + "user-data/"
|
|
51
|
+
|
|
52
|
+
region = getattr(ec2_metadata, "region", None) or env_region
|
|
53
|
+
return InstanceMetadata(
|
|
54
|
+
private_ipv4=getattr(ec2_metadata, "private_ipv4", None),
|
|
55
|
+
region=region,
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
return InstanceMetadata(region=env_region)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
def get_canonical_email(email=None):
|
|
2
|
+
"""
|
|
3
|
+
:param email:
|
|
4
|
+
:return: Returns the lowercase canonical email without `+` or `.` characters.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
if not email:
|
|
8
|
+
return None
|
|
9
|
+
|
|
10
|
+
# Convert email to lowercase
|
|
11
|
+
email = email.lower()
|
|
12
|
+
|
|
13
|
+
delimited_email = email.split('@')
|
|
14
|
+
domain = delimited_email[-1]
|
|
15
|
+
|
|
16
|
+
# Get the significand of an email address (removing any extra @'s)
|
|
17
|
+
significand = ''.join(delimited_email[0:-1])
|
|
18
|
+
|
|
19
|
+
# Strip substring from first `+` to the `@`
|
|
20
|
+
if '+' in significand:
|
|
21
|
+
significand = significand[0:significand.find('+')]
|
|
22
|
+
|
|
23
|
+
return '@'.join([significand, domain])
|