@aurostack/stacks 0.1.1 → 0.2.1

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 (112) hide show
  1. package/README.md +59 -0
  2. package/cli/src/generate.mjs +13 -1
  3. package/cli/stack.mjs +1 -1
  4. package/package.json +1 -1
  5. package/templates/nest-api/files/.bin/secrets.ts +15 -6
  6. package/templates/nest-api/files/.env.example +29 -2
  7. package/templates/nest-api/files/.env.test.example +1 -1
  8. package/templates/nest-api/files/_gitignore +1 -0
  9. package/templates/nest-api/files/compose.yml +16 -0
  10. package/templates/nest-api/files/package.json +18 -3
  11. package/templates/nest-api/files/src/app.module.ts +9 -1
  12. package/templates/nest-api/files/src/app.setup.ts +8 -7
  13. package/templates/nest-api/files/src/common/__tests__/temporal.service.spec.ts +66 -0
  14. package/templates/nest-api/files/src/common/common.module.ts +4 -3
  15. package/templates/nest-api/files/src/common/constants/pino.ts +14 -0
  16. package/templates/nest-api/files/src/common/controllers/index.ts +0 -1
  17. package/templates/nest-api/files/src/common/modules/index.ts +0 -1
  18. package/templates/nest-api/files/src/common/modules/logger.module.ts +5 -8
  19. package/templates/nest-api/files/src/common/services/config.service.ts +43 -5
  20. package/templates/nest-api/files/src/common/services/index.ts +1 -0
  21. package/templates/nest-api/files/src/common/services/temporal.service.ts +66 -0
  22. package/templates/nest-api/files/src/instrumentation.ts +117 -0
  23. package/templates/nest-api/files/src/main.ts +3 -1
  24. package/templates/nest-api/files/src/notifications/services/notifications.service.ts +4 -2
  25. package/templates/nest-api/files/src/telemetry/__tests__/config.spec.ts +67 -0
  26. package/templates/nest-api/files/src/telemetry/config.ts +62 -0
  27. package/templates/nest-api/template.json +54 -14
  28. package/templates/node-worker/files/.bin/secrets.ts +15 -6
  29. package/templates/node-worker/files/.env.example +27 -0
  30. package/templates/node-worker/files/Dockerfile +25 -2
  31. package/templates/node-worker/files/Dockerfile.dev +25 -2
  32. package/templates/node-worker/files/_gitignore +1 -0
  33. package/templates/node-worker/files/ecosystem.config.cjs +2 -0
  34. package/templates/node-worker/files/package.json +24 -4
  35. package/templates/node-worker/files/src/config.ts +23 -0
  36. package/templates/node-worker/files/src/index.ts +40 -4
  37. package/templates/node-worker/files/src/instrumentation.ts +110 -0
  38. package/templates/node-worker/files/src/logger.ts +14 -1
  39. package/templates/node-worker/files/src/telemetry/config.ts +61 -0
  40. package/templates/node-worker/files/src/temporal/activities/index.ts +14 -0
  41. package/templates/node-worker/files/src/temporal/worker.ts +84 -0
  42. package/templates/node-worker/files/src/temporal/workflows/index.ts +23 -0
  43. package/templates/node-worker/files/src/utils/worker.ts +10 -2
  44. package/templates/node-worker/template.json +43 -1
  45. package/templates/py-worker/files/.env.example +28 -0
  46. package/templates/py-worker/files/config.py +32 -1
  47. package/templates/py-worker/files/requirements.txt +16 -0
  48. package/templates/py-worker/files/temporal/__init__.py +0 -0
  49. package/templates/py-worker/files/temporal/activities.py +13 -0
  50. package/templates/py-worker/files/temporal/workflows.py +38 -0
  51. package/templates/py-worker/files/utils/db.py +20 -27
  52. package/templates/py-worker/files/utils/logger.py +9 -2
  53. package/templates/py-worker/files/utils/telemetry.py +154 -0
  54. package/templates/py-worker/files/utils/temporal.py +55 -0
  55. package/templates/py-worker/files/utils/worker.py +14 -3
  56. package/templates/py-worker/files/worker.py +16 -0
  57. package/templates/py-worker/template.json +36 -0
  58. package/templates/react-app/derive.sh +2 -1
  59. package/templates/react-app/files/.bin/secrets.js +8 -2
  60. package/templates/react-app/files/.env.example +11 -0
  61. package/templates/react-app/files/.forgejo/workflows/build-and-push.yml +3 -0
  62. package/templates/react-app/files/Dockerfile +10 -0
  63. package/templates/react-app/files/_gitignore +1 -0
  64. package/templates/react-app/files/build.sh +3 -1
  65. package/templates/react-app/files/package.json +2 -0
  66. package/templates/react-app/files/src/lib/env.ts +10 -0
  67. package/templates/react-app/files/src/main.tsx +4 -0
  68. package/templates/react-app/files/src/shared/auth/provider.tsx +2 -0
  69. package/templates/react-app/files/src/shared/layouts/error-boundary.tsx +2 -0
  70. package/templates/react-app/files/src/shared/telemetry/index.ts +80 -0
  71. package/templates/react-app/template.json +16 -2
  72. package/templates/react-monorepo/files/.forgejo/workflows/build-and-push.yml +15 -11
  73. package/templates/react-monorepo/files/Dockerfile +21 -7
  74. package/templates/react-monorepo/files/_gitignore +1 -0
  75. package/templates/react-monorepo/files/apps/admin/.bin/secrets.js +8 -3
  76. package/templates/react-monorepo/files/apps/admin/.env.example +11 -0
  77. package/templates/react-monorepo/files/apps/admin/package.json +1 -0
  78. package/templates/react-monorepo/files/apps/admin/src/app/router.tsx +11 -7
  79. package/templates/react-monorepo/files/apps/admin/src/lib/env.ts +10 -0
  80. package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
  81. package/templates/react-monorepo/files/apps/auth/.bin/secrets.js +8 -3
  82. package/templates/react-monorepo/files/apps/auth/.env.example +11 -0
  83. package/templates/react-monorepo/files/apps/auth/package.json +1 -0
  84. package/templates/react-monorepo/files/apps/auth/src/lib/env.ts +10 -0
  85. package/templates/react-monorepo/files/apps/auth/src/main.tsx +4 -0
  86. package/templates/react-monorepo/files/apps/client/.bin/secrets.js +8 -3
  87. package/templates/react-monorepo/files/apps/client/.env.example +11 -0
  88. package/templates/react-monorepo/files/apps/client/package.json +1 -0
  89. package/templates/react-monorepo/files/apps/client/src/lib/env.ts +10 -0
  90. package/templates/react-monorepo/files/apps/client/src/main.tsx +4 -0
  91. package/templates/react-monorepo/files/apps/client/src/vite-env.d.ts +1 -1
  92. package/templates/react-monorepo/files/apps/landing/.bin/secrets.js +8 -3
  93. package/templates/react-monorepo/files/apps/landing/.env.example +11 -0
  94. package/templates/react-monorepo/files/apps/landing/package.json +1 -0
  95. package/templates/react-monorepo/files/apps/landing/src/lib/env.ts +10 -0
  96. package/templates/react-monorepo/files/apps/landing/src/main.tsx +4 -0
  97. package/templates/react-monorepo/files/build.sh +3 -1
  98. package/templates/react-monorepo/files/packages/auth/package.json +1 -0
  99. package/templates/react-monorepo/files/packages/auth/src/provider.tsx +2 -0
  100. package/templates/react-monorepo/files/packages/layouts/package.json +1 -0
  101. package/templates/react-monorepo/files/packages/layouts/src/error-boundary.tsx +2 -0
  102. package/templates/react-monorepo/files/packages/telemetry/eslint.config.mjs +3 -0
  103. package/templates/react-monorepo/files/packages/telemetry/package.json +20 -0
  104. package/templates/react-monorepo/files/packages/telemetry/src/index.ts +80 -0
  105. package/templates/react-monorepo/files/packages/telemetry/tsconfig.json +4 -0
  106. package/templates/react-monorepo/files/packages/types/.bin/secrets.js +8 -3
  107. package/templates/react-monorepo/files/turbo.json +1 -1
  108. package/templates/react-monorepo/template.json +87 -9
  109. package/templates/nest-api/files/src/common/controllers/metrics.controller.ts +0 -21
  110. package/templates/nest-api/files/src/common/interceptors/index.ts +0 -1
  111. package/templates/nest-api/files/src/common/interceptors/metrics.interceptor.ts +0 -37
  112. package/templates/nest-api/files/src/common/modules/metrics.module.ts +0 -28
@@ -4,15 +4,31 @@ certifi==2026.5.20
4
4
  charset-normalizer==3.4.7
5
5
  cuid2==2.0.1
6
6
  dotenv==0.9.9
7
+ googleapis-common-protos==1.75.4
7
8
  greenlet==3.5.1
8
9
  idna==3.18
9
10
  msgpack==1.1.2
11
+ nexus-rpc==1.4.0
12
+ opentelemetry-api==1.45.0
13
+ opentelemetry-exporter-http-transport==0.66b0
14
+ opentelemetry-exporter-otlp-common==0.66b0
15
+ opentelemetry-exporter-otlp-proto-common==1.45.0
16
+ opentelemetry-exporter-otlp-proto-http==1.45.0
17
+ opentelemetry-instrumentation==0.66b0
18
+ opentelemetry-instrumentation-redis==0.66b0
19
+ opentelemetry-proto==1.45.0
20
+ opentelemetry-sdk==1.45.0
21
+ opentelemetry-semantic-conventions==0.66b0
22
+ protobuf==7.36.2
10
23
  psycopg2-binary==2.9.12
11
24
  python-dotenv==1.2.2
12
25
  redis==6.4.0
13
26
  requests==2.34.2
14
27
  semver==3.0.4
15
28
  SQLAlchemy==2.0.50
29
+ temporalio==1.33.0
30
+ types-protobuf==7.35.1.20260906
16
31
  typing_extensions==4.15.0
17
32
  urllib3==2.7.0
18
33
  websockets==16.0
34
+ wrapt==2.5.0
File without changes
@@ -0,0 +1,13 @@
1
+ from temporalio import activity
2
+
3
+
4
+ @activity.defn
5
+ async def greet(name: str) -> str:
6
+ """
7
+ Activities do the real work: I/O, database writes, calls to other
8
+ services. Temporal retries a failed activity on its own (per the retry
9
+ policy set in the workflow), so raise on failure and make each one safe to
10
+ run twice.
11
+ """
12
+ activity.logger.info("Greeting %s", name)
13
+ return f"Hello, {name}!"
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass
2
+ from datetime import timedelta
3
+
4
+ from temporalio import workflow
5
+ from temporalio.common import RetryPolicy
6
+
7
+ # Workflows run in a sandbox that re-imports modules for each run; activities
8
+ # are only referenced here, never executed, so pass them straight through.
9
+ with workflow.unsafe.imports_passed_through():
10
+ from temporal.activities import greet
11
+
12
+
13
+ @dataclass
14
+ class ExampleInput:
15
+ name: str
16
+
17
+
18
+ @workflow.defn(name="example")
19
+ class Example:
20
+ """
21
+ Workflows orchestrate; activities act. This code is replayed from history
22
+ to rebuild state, so it must be deterministic: no I/O, no datetime.now() or
23
+ random of its own (use workflow.now() and workflow.random()), nothing that
24
+ touches the network or filesystem. Anything like that belongs in an
25
+ activity.
26
+ """
27
+
28
+ @workflow.run
29
+ async def run(self, input: ExampleInput) -> str:
30
+ # A durable timer: survives worker restarts and deploys, for as long
31
+ # as you like (timedelta(days=30) works the same way).
32
+ await workflow.sleep(timedelta(seconds=1))
33
+ return await workflow.execute_activity(
34
+ greet,
35
+ input.name,
36
+ start_to_close_timeout=timedelta(minutes=1),
37
+ retry_policy=RetryPolicy(maximum_attempts=5),
38
+ )
@@ -1,37 +1,22 @@
1
1
  """
2
- @File: db.py
3
- @Version: 1.0
2
+ Database access over SQLAlchemy, with the connection settings from config
3
+ (DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME).
4
4
 
5
- Database wrapper to manage PostgreSQL connections using SQLAlchemy
5
+ Usage:
6
6
 
7
- DB('my_database') will create a connection to the specified PostgreSQL database
8
- using credentials from the .env file (user, password, host, port).
7
+ from utils.db import DB
9
8
 
10
- Usage example:
11
-
12
- from utils import DB
13
-
14
- db = DB('my_database')
15
-
16
- # Read data
17
- df = db.read_sql('SELECT * FROM my_table')
18
-
19
- # Write data
20
- db.write_sql(df, 'my_table', if_exists='append')
21
-
22
- # Use engine or session without having to create a new connection or variable
23
- db.session.query(...)
24
- db.engine.execute(...)
25
- # ... perform operations ...
9
+ db = DB()
10
+ rows = db.session.execute(select(MyModel)).scalars().all()
26
11
  db.session.commit()
27
-
28
- # Close the connection when done
29
12
  db.close()
30
13
 
14
+ The models under models/ are hand-written to match the API's Prisma schema:
15
+ the API owns the tables and their migrations.
31
16
  """
32
17
 
33
18
  from sqlalchemy import create_engine
34
- from sqlalchemy.engine import Connection
19
+ from sqlalchemy.engine import URL, Connection
35
20
  from sqlalchemy.orm import sessionmaker, Session
36
21
 
37
22
  from config import CONFIG
@@ -40,9 +25,17 @@ from config import CONFIG
40
25
  class DB:
41
26
  def __init__(self):
42
27
  db = CONFIG.env.db
43
- self._engine = create_engine(
44
- f"postgresql://{db.user}:{db.password}@{db.host}:{db.port}/{db.name}"
45
- ).connect()
28
+ # URL.create escapes the credentials, so a password with @, / or : in it
29
+ # can't break the connection string.
30
+ url = URL.create(
31
+ "postgresql",
32
+ username=db.user,
33
+ password=db.password,
34
+ host=db.host,
35
+ port=db.port,
36
+ database=db.name,
37
+ )
38
+ self._engine = create_engine(url).connect()
46
39
  Session = sessionmaker(bind=self.engine)
47
40
  self._session = Session()
48
41
 
@@ -2,7 +2,8 @@
2
2
  @File: logger.py
3
3
  @Version: 1.1
4
4
 
5
- Logger wrapper to manage logs in one place with log rotate
5
+ Logger wrapper to manage logs in one place with log rotate. Records also go to
6
+ stdout, so `docker logs` shows them.
6
7
 
7
8
  Logger('daily') will create daily.log file in the logs folder
8
9
  Logger('my.daily') will create my.daily.log file in the logs folder
@@ -35,6 +36,7 @@ except Exception as e:
35
36
  import logging
36
37
  import os
37
38
  import pathlib
39
+ import sys
38
40
  from logging import handlers
39
41
 
40
42
  from config import CONFIG
@@ -49,7 +51,7 @@ class Logger(object):
49
51
  if module is not None:
50
52
  directory = f"{directory}/{module}"
51
53
  logger_name = f"{module}_{name}"
52
- pathlib.Path(directory).mkdir(parents=True, exist_ok=True)
54
+ pathlib.Path(directory).mkdir(parents=True, exist_ok=True)
53
55
 
54
56
  filename = f"{directory}/{name}.log"
55
57
  formatter = "%(asctime)s %(levelname)-10s %(message)s"
@@ -74,6 +76,11 @@ class Logger(object):
74
76
  handler.setFormatter(formatter)
75
77
  self.logger.addHandler(handler)
76
78
 
79
+ console = logging.StreamHandler(sys.stdout)
80
+ console.setLevel(logging.INFO)
81
+ console.setFormatter(formatter)
82
+ self.logger.addHandler(console)
83
+
77
84
  def debug(self, message, exc_info=None):
78
85
  self.__call(self.logger.debug, message, exc_info)
79
86
 
@@ -0,0 +1,154 @@
1
+ """
2
+ OpenTelemetry: traces, logs and metrics over OTLP, built for OpenObserve.
3
+
4
+ Two ways to connect, in order of precedence:
5
+ - the standard OpenTelemetry variables (OTEL_EXPORTER_OTLP_ENDPOINT and
6
+ friends), which the exporters read themselves, for any OTLP backend;
7
+ - OPENOBSERVE_URL + OPENOBSERVE_ORG + OPENOBSERVE_TOKEN, from which the
8
+ OpenObserve endpoints and the org ingestion-token header are built.
9
+ With neither, setup_telemetry() does nothing and job_span() is a no-op.
10
+ """
11
+ import base64
12
+ import logging
13
+ import os
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass, field
16
+
17
+ from opentelemetry import trace
18
+
19
+ DEFAULT_SERVICE_NAME = "acme-py"
20
+
21
+ _providers: list = []
22
+
23
+
24
+ @dataclass
25
+ class TelemetryConfig:
26
+ enabled: bool
27
+ service_name: str
28
+ environment: str
29
+ # Signal URLs; empty means the exporters resolve them from OTEL_* vars.
30
+ urls: dict = field(default_factory=dict)
31
+ headers: dict = field(default_factory=dict)
32
+
33
+
34
+ def resolve_telemetry(env=os.environ) -> TelemetryConfig:
35
+ service_name = env.get("OTEL_SERVICE_NAME") or DEFAULT_SERVICE_NAME
36
+ environment = env.get("APP_ENV") or "development"
37
+ off = TelemetryConfig(False, service_name, environment)
38
+
39
+ if env.get("OTEL_SDK_DISABLED") == "true":
40
+ return off
41
+ if env.get("OTEL_EXPORTER_OTLP_ENDPOINT"):
42
+ return TelemetryConfig(True, service_name, environment)
43
+
44
+ url, org, token = (
45
+ env.get("OPENOBSERVE_URL"),
46
+ env.get("OPENOBSERVE_ORG"),
47
+ env.get("OPENOBSERVE_TOKEN"),
48
+ )
49
+ if not (url and org and token):
50
+ return off
51
+
52
+ # OTLP/HTTP endpoints live under /api/<org>; an org ingestion token
53
+ # authenticates as `<org>:<token>`. Logs and traces land in the stream
54
+ # named here, one per service.
55
+ base = f"{url.rstrip('/')}/api/{org}"
56
+ credentials = base64.b64encode(f"{org}:{token}".encode()).decode()
57
+ return TelemetryConfig(
58
+ True,
59
+ service_name,
60
+ environment,
61
+ urls={
62
+ "traces": f"{base}/v1/traces",
63
+ "logs": f"{base}/v1/logs",
64
+ "metrics": f"{base}/v1/metrics",
65
+ },
66
+ headers={
67
+ "Authorization": f"Basic {credentials}",
68
+ "stream-name": env.get("OPENOBSERVE_STREAM") or service_name,
69
+ },
70
+ )
71
+
72
+
73
+ def setup_telemetry() -> bool:
74
+ """Start exporting, if connection settings are present. Call once, first."""
75
+ config = resolve_telemetry()
76
+ if not config.enabled:
77
+ return False
78
+
79
+ # Imported here so a worker without telemetry never loads the SDK.
80
+ from opentelemetry._logs import set_logger_provider
81
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
82
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
83
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
84
+ from opentelemetry.instrumentation.redis import RedisInstrumentor
85
+ from opentelemetry.metrics import set_meter_provider
86
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
87
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
88
+ from opentelemetry.sdk.metrics import MeterProvider
89
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
90
+ from opentelemetry.sdk.resources import Resource
91
+ from opentelemetry.sdk.trace import TracerProvider
92
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
93
+
94
+ def exporter_args(signal: str) -> dict:
95
+ if not config.urls:
96
+ return {}
97
+ return {"endpoint": config.urls[signal], "headers": config.headers}
98
+
99
+ resource = Resource.create(
100
+ {
101
+ "service.name": config.service_name,
102
+ "deployment.environment.name": config.environment,
103
+ }
104
+ )
105
+
106
+ tracer_provider = TracerProvider(resource=resource)
107
+ tracer_provider.add_span_processor(
108
+ BatchSpanProcessor(OTLPSpanExporter(**exporter_args("traces")))
109
+ )
110
+ trace.set_tracer_provider(tracer_provider)
111
+
112
+ logger_provider = LoggerProvider(resource=resource)
113
+ logger_provider.add_log_record_processor(
114
+ BatchLogRecordProcessor(OTLPLogExporter(**exporter_args("logs")))
115
+ )
116
+ set_logger_provider(logger_provider)
117
+ # Every utils.logger.Logger propagates to the root logger.
118
+ logging.getLogger().addHandler(LoggingHandler(logger_provider=logger_provider))
119
+
120
+ meter_provider = MeterProvider(
121
+ resource=resource,
122
+ metric_readers=[
123
+ PeriodicExportingMetricReader(
124
+ OTLPMetricExporter(**exporter_args("metrics"))
125
+ )
126
+ ],
127
+ )
128
+ set_meter_provider(meter_provider)
129
+
130
+ RedisInstrumentor().instrument()
131
+ _providers.extend([tracer_provider, logger_provider, meter_provider])
132
+ return True
133
+
134
+
135
+ def shutdown_telemetry() -> None:
136
+ """Flush whatever is buffered. Safe to call when telemetry is off."""
137
+ for provider in _providers:
138
+ provider.shutdown()
139
+
140
+
141
+ @contextmanager
142
+ def job_span(queue_name: str, job):
143
+ """One span per job, so each job is its own trace in OpenObserve."""
144
+ tracer = trace.get_tracer("worker")
145
+ with tracer.start_as_current_span(
146
+ f"process {queue_name}",
147
+ attributes={"messaging.system": "bullmq", "messaging.destination.name": queue_name, "bullmq.job.id": str(job.id)},
148
+ ) as span:
149
+ try:
150
+ yield span
151
+ except Exception as error:
152
+ span.record_exception(error)
153
+ span.set_status(trace.Status(trace.StatusCode.ERROR, str(error)))
154
+ raise
@@ -0,0 +1,55 @@
1
+ from temporalio.client import Client, TLSConfig
2
+ from temporalio.worker import Worker
3
+
4
+ from config import CONFIG, TemporalTLS
5
+ from temporal.activities import greet
6
+ from temporal.workflows import Example
7
+
8
+
9
+ def temporal_tls(tls: TemporalTLS) -> TLSConfig | bool:
10
+ """
11
+ mTLS settings from PEM strings; none at all means plaintext (False).
12
+ Escaped \\n are unescaped, so a certificate fits on one line of an env file.
13
+ """
14
+ if not (tls.ca or tls.cert or tls.key):
15
+ return False
16
+ if not (tls.cert and tls.key):
17
+ raise ValueError("TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must be set together")
18
+
19
+ def pem(value: str) -> bytes:
20
+ return value.replace("\\n", "\n").encode()
21
+
22
+ return TLSConfig(
23
+ server_root_ca_cert=pem(tls.ca) if tls.ca else None,
24
+ client_cert=pem(tls.cert),
25
+ client_private_key=pem(tls.key),
26
+ )
27
+
28
+
29
+ async def create_temporal_worker() -> Worker:
30
+ """
31
+ Connect to Temporal and build a worker for TEMPORAL_TASK_QUEUE, running the
32
+ workflows and activities in temporal/. Register new ones in the lists below.
33
+ """
34
+ settings = CONFIG.env.temporal
35
+ interceptors = []
36
+ # @feature:start telemetry
37
+ # A span per workflow and activity, continuing the trace of whatever
38
+ # started the workflow; a no-op while telemetry is off.
39
+ from temporalio.contrib.opentelemetry import TracingInterceptor
40
+
41
+ interceptors.append(TracingInterceptor())
42
+ # @feature:end
43
+
44
+ client = await Client.connect(
45
+ settings.address,
46
+ namespace=settings.namespace,
47
+ tls=temporal_tls(settings.tls),
48
+ interceptors=interceptors,
49
+ )
50
+ return Worker(
51
+ client,
52
+ task_queue=settings.task_queue,
53
+ workflows=[Example],
54
+ activities=[greet],
55
+ )
@@ -3,8 +3,9 @@ from bullmq import Worker, Job
3
3
  from urllib.parse import urlparse, quote, urlencode
4
4
 
5
5
  from config import CONFIG, Redis
6
- from utils.db import DB
6
+ from utils.db import DB # @feature database
7
7
  from utils.logger import Logger
8
+ from utils.telemetry import job_span # @feature telemetry
8
9
 
9
10
 
10
11
  def build_redis_url(options: Redis):
@@ -39,9 +40,19 @@ def create_worker(queue_name: str, process_function: Callable, concurrency: int
39
40
  An instance of BullMQ Worker.
40
41
  """
41
42
  connection = build_redis_url(CONFIG.env.redis)
43
+
44
+ processor = process_function
45
+ # @feature:start telemetry
46
+ async def traced(job: Job, token: str):
47
+ with job_span(queue_name, job):
48
+ return await process_function(job, token)
49
+
50
+ processor = traced
51
+ # @feature:end
52
+
42
53
  worker = Worker(
43
54
  queue_name,
44
- process_function,
55
+ processor,
45
56
  {
46
57
  "connection": connection,
47
58
  "autorun": True,
@@ -73,7 +84,7 @@ class JobUtil:
73
84
  await self._job.updateProgress(progress)
74
85
  await self._job.log(message)
75
86
 
76
- async def finalize(self, db: DB | None = None):
87
+ async def finalize(self, db: "DB | None" = None):
77
88
  if db:
78
89
  db.close()
79
90
  await self._job.updateProgress(100)
@@ -4,6 +4,8 @@ import asyncio
4
4
  import signal
5
5
  from utils.logger import Logger
6
6
  from utils.worker import create_worker
7
+ from utils.telemetry import setup_telemetry, shutdown_telemetry # @feature telemetry
8
+ from utils.temporal import create_temporal_worker # @feature temporal
7
9
 
8
10
  # import worker processes
9
11
  from workers.hello.process import hello_process
@@ -11,6 +13,7 @@ logger = Logger("worker")
11
13
 
12
14
 
13
15
  async def main():
16
+ setup_telemetry() # @feature telemetry
14
17
  logger.info("Starting Python 3.14 Worker...")
15
18
  shutdown_event = asyncio.Event()
16
19
  loop = asyncio.get_running_loop()
@@ -27,6 +30,12 @@ async def main():
27
30
 
28
31
  # Add workers
29
32
  hello = create_worker("hello", hello_process)
33
+ # @feature:start temporal
34
+ temporal_worker = await create_temporal_worker()
35
+ temporal_run = asyncio.create_task(temporal_worker.run())
36
+ # If the Temporal worker stops on its own (a fatal error), stop everything.
37
+ temporal_run.add_done_callback(lambda _: shutdown_event.set())
38
+ # @feature:end
30
39
 
31
40
  logger.info("Workers started and waiting for jobs.")
32
41
  try:
@@ -35,7 +44,14 @@ async def main():
35
44
  logger.info("Cleaning up workers...")
36
45
  # Close workers gracefully
37
46
  await hello.close()
47
+ # @feature:start temporal
48
+ # Stops polling and waits for running activities to finish.
49
+ if not temporal_run.done():
50
+ await temporal_worker.shutdown()
51
+ await temporal_run
52
+ # @feature:end
38
53
  logger.info("Workers shut down successfully.")
54
+ shutdown_telemetry() # @feature telemetry
39
55
 
40
56
  if __name__ == "__main__":
41
57
  asyncio.run(main())
@@ -55,6 +55,42 @@
55
55
  "files": [
56
56
  ".forgejo/**"
57
57
  ]
58
+ },
59
+ "telemetry": {
60
+ "title": "OpenTelemetry → OpenObserve",
61
+ "description": "Traces, logs and metrics over OTLP (set OPENOBSERVE_URL/ORG/TOKEN, or any OTEL_* backend), with a span per job and Redis instrumentation. Off at runtime until connected.",
62
+ "default": true,
63
+ "files": [
64
+ "utils/telemetry.py"
65
+ ],
66
+ "requirements": [
67
+ "googleapis-common-protos",
68
+ "opentelemetry-api",
69
+ "opentelemetry-exporter-http-transport",
70
+ "opentelemetry-exporter-otlp-common",
71
+ "opentelemetry-exporter-otlp-proto-common",
72
+ "opentelemetry-exporter-otlp-proto-http",
73
+ "opentelemetry-instrumentation",
74
+ "opentelemetry-instrumentation-redis",
75
+ "opentelemetry-proto",
76
+ "opentelemetry-sdk",
77
+ "opentelemetry-semantic-conventions",
78
+ "protobuf",
79
+ "wrapt"
80
+ ]
81
+ },
82
+ "temporal": {
83
+ "title": "Temporal worker",
84
+ "description": "Runs durable workflows and activities from temporal/ (an example of each included) on its own TEMPORAL_TASK_QUEUE, next to the BullMQ consumers, with a span per workflow and activity when telemetry is on. Pair with the API's temporal feature, which starts them.",
85
+ "files": [
86
+ "utils/temporal.py",
87
+ "temporal/**"
88
+ ],
89
+ "requirements": [
90
+ "nexus-rpc",
91
+ "temporalio",
92
+ "types-protobuf"
93
+ ]
58
94
  }
59
95
  },
60
96
  "hooks": [
@@ -52,7 +52,7 @@ done
52
52
 
53
53
  echo "==> flattening packages -> src/shared"
54
54
  mkdir -p "$DST/src/shared"
55
- for pkg in ui api auth layouts hooks types; do
55
+ for pkg in ui api auth layouts hooks types telemetry; do
56
56
  cp -r "$SRC/packages/$pkg/src" "$DST/src/shared/$pkg"
57
57
  done
58
58
  # The brand tokens live in the config package in the monorepo because three
@@ -94,6 +94,7 @@ find "$DST/src" -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) -pr
94
94
  -e "s#@acme/auth#@/shared/auth#g" \
95
95
  -e "s#@acme/layouts#@/shared/layouts#g" \
96
96
  -e "s#@acme/hooks#@/shared/hooks#g" \
97
+ -e "s#@acme/telemetry#@/shared/telemetry#g" \
97
98
  -e "s#@acme/types#@/shared/types#g"
98
99
 
99
100
  # The auth screens moved one level deeper than their components and lib, so
@@ -1,4 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
+ import { writeFileSync } from 'node:fs';
2
3
 
3
4
  // Pulls this app's env from Infisical into the matching .env file.
4
5
  //
@@ -42,6 +43,11 @@ const env = normalizeEnv(parseEnvArg());
42
43
  const envFile = ENV_FILES[env];
43
44
 
44
45
  console.log(`Fetching secrets (${env}) -> ${envFile}...`);
45
- execSync(`infisical export --projectId="${PROJECT_ID}" --path="${SECRET_PATH}" --env=${env} > ${envFile}`, {
46
- stdio: 'inherit'
46
+ // Export first and write only on success: a shell `>` redirect would empty
47
+ // the existing file before infisical had the chance to fail. execSync throws
48
+ // (and the process exits non-zero) when infisical fails.
49
+ const output = execSync(`infisical export --projectId="${PROJECT_ID}" --path="${SECRET_PATH}" --env=${env}`, {
50
+ stdio: ['inherit', 'pipe', 'inherit']
47
51
  });
52
+ writeFileSync(envFile, output);
53
+ console.log(`Wrote ${envFile}`);
@@ -11,6 +11,17 @@ PORT='3000'
11
11
  # Needed when you reach the dev server through a tunnel or a wildcard DNS name.
12
12
  VITE_APP_ALLOWED_HOSTS=''
13
13
 
14
+ # @feature:start telemetry
15
+ # OpenObserve browser monitoring (RUM): page views, errors, slow requests, and
16
+ # trace headers on API calls. Leave empty and it stays off. The client token is
17
+ # a RUM token (OpenObserve → Ingestion → RUM): write-only and meant to ship in
18
+ # the bundle — never the backend's ingestion token.
19
+ # The instance must list this app's origin in ZO_CORS_ALLOWED_ORIGINS.
20
+ VITE_OPENOBSERVE_URL='https://o2.aurostack.co'
21
+ VITE_OPENOBSERVE_ORG=''
22
+ VITE_OPENOBSERVE_CLIENT_TOKEN=''
23
+ # @feature:end
24
+
14
25
  # @feature:start marketing
15
26
  # The canonical public origin — the ONE origin that may be indexed. Must match
16
27
  # PRODUCTION_ORIGIN in src/features/seo/routes.ts. Anything else (previews,
@@ -47,6 +47,9 @@ jobs:
47
47
  VITE_APP_API_URL=${{ secrets.VITE_APP_API_URL }}
48
48
  VITE_APP_SITE_URL=${{ secrets.VITE_APP_SITE_URL }}
49
49
  VITE_APP_SUPPORT_EMAIL=${{ secrets.VITE_APP_SUPPORT_EMAIL }}
50
+ VITE_OPENOBSERVE_URL=${{ secrets.VITE_OPENOBSERVE_URL }} # @feature telemetry
51
+ VITE_OPENOBSERVE_ORG=${{ secrets.VITE_OPENOBSERVE_ORG }} # @feature telemetry
52
+ VITE_OPENOBSERVE_CLIENT_TOKEN=${{ secrets.VITE_OPENOBSERVE_CLIENT_TOKEN }} # @feature telemetry
50
53
  tags: |
51
54
  ${{ secrets.REGISTRY }}/${{ steps.tag.outputs.OWNER }}/web:latest
52
55
  ${{ secrets.REGISTRY }}/${{ steps.tag.outputs.OWNER }}/web:${{ steps.tag.outputs.TAG }}
@@ -35,6 +35,16 @@ ARG VITE_APP_SUPPORT_EMAIL
35
35
  ENV VITE_APP_API_URL=$VITE_APP_API_URL \
36
36
  VITE_APP_SITE_URL=$VITE_APP_SITE_URL \
37
37
  VITE_APP_SUPPORT_EMAIL=$VITE_APP_SUPPORT_EMAIL
38
+ # @feature:start telemetry
39
+ # Browser monitoring. Leave unset and the bundle ships with telemetry off. The
40
+ # client token is a RUM token, write-only and meant to be public.
41
+ ARG VITE_OPENOBSERVE_URL
42
+ ARG VITE_OPENOBSERVE_ORG
43
+ ARG VITE_OPENOBSERVE_CLIENT_TOKEN
44
+ ENV VITE_OPENOBSERVE_URL=$VITE_OPENOBSERVE_URL \
45
+ VITE_OPENOBSERVE_ORG=$VITE_OPENOBSERVE_ORG \
46
+ VITE_OPENOBSERVE_CLIENT_TOKEN=$VITE_OPENOBSERVE_CLIENT_TOKEN
47
+ # @feature:end
38
48
 
39
49
  # Fail loudly rather than silently producing a bundle with `undefined` URLs baked
40
50
  # in — that failure is invisible until the app 404s at runtime.
@@ -22,6 +22,7 @@ dev-dist/
22
22
  .env.*.local
23
23
  .env.test
24
24
  .env.production
25
+ .env.staging
25
26
 
26
27
  # editor / os
27
28
  .DS_Store
@@ -38,7 +38,9 @@ read_var() {
38
38
  }
39
39
 
40
40
  BUILD_ARGS=""
41
- for VAR in VITE_APP_API_URL VITE_APP_SITE_URL VITE_APP_SUPPORT_EMAIL; do
41
+ VARS="VITE_APP_API_URL VITE_APP_SITE_URL VITE_APP_SUPPORT_EMAIL"
42
+ VARS="$VARS VITE_OPENOBSERVE_URL VITE_OPENOBSERVE_ORG VITE_OPENOBSERVE_CLIENT_TOKEN" # @feature telemetry
43
+ for VAR in $VARS; do
42
44
  VALUE="$(read_var "$VAR")"
43
45
  [ -n "$VALUE" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $VAR=$VALUE"
44
46
  done
@@ -26,6 +26,8 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@fontsource-variable/inter": "^5.3.0",
29
+ "@openobserve/browser-logs": "^0.4.3",
30
+ "@openobserve/browser-rum": "^0.4.3",
29
31
  "@radix-ui/react-label": "^2.1.7",
30
32
  "@radix-ui/react-popover": "^1.1.21",
31
33
  "@radix-ui/react-select": "^2.3.4",
@@ -26,3 +26,13 @@ export const APP_ORIGIN = window.location.origin;
26
26
  */
27
27
  export const SITE_URL = (env.VITE_APP_SITE_URL as string | undefined) ?? '';
28
28
  // @feature:end
29
+
30
+ // @feature:start telemetry
31
+ /** OpenObserve browser monitoring (see @/shared/telemetry); unset keeps it off. */
32
+ export const TELEMETRY = {
33
+ url: env.VITE_OPENOBSERVE_URL as string | undefined,
34
+ org: env.VITE_OPENOBSERVE_ORG as string | undefined,
35
+ clientToken: env.VITE_OPENOBSERVE_CLIENT_TOKEN as string | undefined,
36
+ environment: env.MODE
37
+ };
38
+ // @feature:end