Move public changelog registry ownership to the scrubber Lambda - #3738
Conversation
b488f2f to
b0042e1
Compare
| namespace Elastic.Documentation.Lambda.ChangelogScrubber; | ||
|
|
||
| /// <summary> | ||
| /// Emits the per-invocation reconcile counters as a CloudWatch Embedded Metric Format line |
There was a problem hiding this comment.
This is overly defensive IMO, we use AOT trimming and source generated json contracts heavily. We should not fallback to handcrafted json emission.
These are emitted as cloudwatch metrics, should we push (some of) them as OTEL metrics too?
Mpdreamz
left a comment
There was a problem hiding this comment.
Request changes: drop per-product changelogs/<product>/registry.json
Pushing back on the core assumption in this stack — that we need (and should reconcile) a per-product / per-group changelogs/<product>/registry.json as a description of every changelog object under that prefix.
Why we don't need it
Release-note discovery is not "walk the registry and see what's there." The automated release discover moments (see elastic/docs-eng-team#698) always start from a list of PRs:
- Prestage → query for list of PRs
- GitHub release → already have a list of PRs
- Serverless release → query for list of PRs
Given that list, we can hit CloudFront and decide whether a checked-in changelog exists by whether the object returns 404. If it 404s, changelog bundle inlines a changelog for that PR into the bundle anyway.
The changelogs/ tree on S3 is only a mirror of explicitly checked-in changelogs in repositories. Those objects are not mandatory to exist at bundle time. A growing changelogs/<product>/registry.json (or changelogs/products/registry.json) would just accumulate forever for little consumer value — please remove that model and the reconcile/heal machinery built around it.
A lot of the code in this PR (group listing → entry-level reconcile → per-group manifest write/delete, producer/schema cutover heal, etc.) exists to keep that per-product registry correct. With the assumption dropped, that surface can shrink substantially.
What we do want instead
Keep (or introduce) two shallow folder-level registries on S3 for cache opt-out only:
changelogs/registry.jsonbundles/registry.json(orbundle/registry.json— match existing key layout)
Shape:
{
"<folder>": "<etag>"
}Where <etag> is the ETag of the last-touched file under that folder. Caching layers can then bail out eagerly before diving into a per-folder / per-product bundle registry.
Please rework this PR around that thinner contract: scrubber remains the public-bucket writer for changelog objects, but it should maintain the shallow folder→etag map — not a full per-product object inventory.
|
Agreed on the thinner contract. I'll rework this PR so the scrubber maintains the shallow folder→ETag maps ( |
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
Phase 1 of elastic/docs-eng-team#688. The public registry.json was a log of upload operations (client-written, pass-through copied); every known consistency gap followed from that. The scrubber Lambda now derives it from the public bucket's actual state: registry = f(state), never f(event). - Extract the Lambda's top-level handler logic into testable classes in Elastic.Changelog: ScrubberProcessor (batch coalescing by key and group, object-level reconcile with post-write source validation) and RegistryReconciler (delimited/paginated group listing, ETag reuse with amends always recomputed, semantic idempotence, conditional PUT/DELETE with bounded jittered retries on 412/409, newer-schema refusal). Program.cs is now a thin adapter. - Retire the registry pass-through in the same deploy: registry-key events only schedule a group reconcile, so client-authored JSON no longer reaches the public bucket uninspected. - Add a producer (algorithm version) field to the manifest; a mismatch — including legacy pass-through manifests — forces a full metadata recompute and a write even when entries are identical. - Emit per-invocation reconcile metrics as CloudWatch EMF (the Phase 0 observability item that could only land with the reconciler). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…only
Rework after review: per-pool changelog/{org}/{repo}/{branch}/registry.json
manifests are no longer reconciled — they stay client-authored and are
mirrored verbatim (state-decided, so deletes propagate) until Phase 3
retires them. Group reconciliation is scoped to bundle/{product}/, the only
tree consumers enumerate.
The scrubber now also maintains shallow per-tree maps (bundle/registry.json
and changelog/registry.json) mapping each folder to an opaque change token,
so caching layers can skip unchanged folders with one small read. The token
digests the folder's sorted file/ETag listing rather than reusing the
last-touched file's ETag: deleting an older file must change the token.
8f92f56 to
7f4181c
Compare
Rename RegistryReconciler to BundleRegistryReconciler: since the rework it
is scoped to the bundle/{product}/ tree only, and the old name read as if
it still covered the changelog pool manifests the review asked to drop.
Fix Registry.cs doc comments that predate the rework: public bundle
indexes are reconciler-owned (not mirrored verbatim), and their recorded
ETag is now the public object's — valid for CDN cache validation — while
client-authored manifests keep recording the private pre-scrub ETag.
|
Pushed b7fe622 with two clarity fixes, and I want to close an ambiguity my own 2026-08-06 reply created, so the deviation from the review is a recorded decision rather than a surprise at re-review time. What my reply promised vs. what the rework does. I wrote I'd have the scrubber maintain the shallow maps "instead of per-product object manifests, and drop the group reconcile/heal machinery" — without qualifying that this referred to the On the surface that remains: group listing → entry reconcile → manifest write/delete → producer cutover heal all still exist, but scoped to the bundle tree only — the minimum for Second deviation, asking for an explicit ok: the shallow-map value is a digest over the folder's sorted file/ETag listing, not the last-touched file's ETag. Deleting an older file doesn't change the newest object's ETag, so deletions would be invisible to caches; consumers treat the value as an opaque change token ( The commit itself: renamed |
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
* Changelog: add temporary migrate-from-web command
* Changelog: test and document migrate-from-web
* Changelog: migrate-from-web writes bundles only (no registry refresh)
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
* Changelog: migrate-from-web covers the scope table, --products narrows
Review feedback (#3794): drop the standing config surface. The former
config/migrate-from-web.yml becomes a checked-in table in the command
itself (MigrateFromWebScope.All) — temporary tooling state, added per
rollout wave and deleted with the command. A run covers every product
in the table by default; --products x,y,z narrows it for tests and
pilots. The page→product mapping stays explicit because bundle product
ids appear in no published metadata: page frontmatter carries the site
taxonomy, not bundle ids.
One product's failure no longer aborts the run: each product migrates
independently and the run fails at the end if any of them did.
* FileSystemFactory: extend the mock-temp scope workaround to Windows
MockFileSystem hardcodes its temp path on every OS (C:\temp on Windows,
unix-ified /temp elsewhere) instead of calling Path.GetTempPath(), while
AllowedSpecialFolder.Temp resolves the real temp. The existing workaround
only covered non-Windows, assuming the two coincide there — they don't:
GitHub runners' real temp is under the user profile, so the first test to
write through mockFs.Path.GetTempPath() on Windows (migrate-from-web's
bundle staging) failed scope validation. Add the inner mock's temp as an
explicit root unconditionally; drop once TestableIO#1454 ships.
The draft predated the #3738 review rework: RegistryReconciler is now BundleRegistryReconciler and reconciles the bundle/{product}/ tree only, the changelog/ pool manifests remain legacy client-authored pass-through (changelog bundle still enumerates pools through them, RFC #698 replaces that), the registry reconcile/verify operator CLI was dropped with #3741, and the scrubber now also maintains the shallow per-tree folder-to-token maps (consumer side: #3801). Infra bullets match the applied docs-infra#360 IAM (no registry-operator grant, no private ListBucket) and observability as it exists (metric stream to docs-o11y; alerts and runbook tracked in docs-eng-team#692).
* Scrubber Lambda owns the public changelog registry via state reconcile
Phase 1 of elastic/docs-eng-team#688. The public registry.json was a log of
upload operations (client-written, pass-through copied); every known
consistency gap followed from that. The scrubber Lambda now derives it from
the public bucket's actual state: registry = f(state), never f(event).
- Extract the Lambda's top-level handler logic into testable classes in
Elastic.Changelog: ScrubberProcessor (batch coalescing by key and group,
object-level reconcile with post-write source validation) and
RegistryReconciler (delimited/paginated group listing, ETag reuse with
amends always recomputed, semantic idempotence, conditional PUT/DELETE
with bounded jittered retries on 412/409, newer-schema refusal).
Program.cs is now a thin adapter.
- Retire the registry pass-through in the same deploy: registry-key events
only schedule a group reconcile, so client-authored JSON no longer
reaches the public bucket uninspected.
- Add a producer (algorithm version) field to the manifest; a mismatch —
including legacy pass-through manifests — forces a full metadata
recompute and a write even when entries are identical.
- Emit per-invocation reconcile metrics as CloudWatch EMF (the Phase 0
observability item that could only land with the reconciler).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add changelog registry reconcile and verify commands
Phase 2 of elastic/docs-eng-team#688. The cutover/heal tooling for the
Lambda-owned public registry:
- `changelog registry reconcile` plans groups (one scope, or the union of
both buckets so orphan public groups are covered) and sends one versioned,
discriminated reconcile message per group to the scrubber queue —
{kind, version, scope, group, correlation_id}, validated through
ChangelogKeys on both ends. The CLI never mutates S3; the Lambda stays the
public bucket's single writer. --dry-run prints the plan; the non-dry-run
path asks for confirmation (--yes for CI). Each run stamps one correlation
id and prints a ledger line per group.
- On a reconcile message the Lambda performs a full group heal:
object-level reconcile over the union of both buckets' listings (copy
what's live, delete what isn't), then the group reconcile — recovering
lost/DLQ-expired scrub events. Requires the new optional
PRIVATE_BUCKET_NAME Lambda env var; malformed messages are rejected to
the DLQ where the Phase 0 alarm surfaces them.
- `changelog registry verify` is the read-only sibling and cutover gate:
compares each public manifest against what a reconcile would write (same
listing spec and entry rules by construction) and reports divergence as
missing/stale/corrupt/object-divergent, with unsupported schemas reported
distinctly.
- Fix the manifest ETag wire format: the snake_case policy serialized the
producer-side field as "e_tag" while consumers and the documented format
read "etag" — recorded ETags were invisible to every consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Suppress CA1001 on ChangelogRegistryServiceTests
Same suppression RegistryBuilderTests carries: xUnit owns the test class
lifetime and TestDiagnosticsCollector needs no disposal in these tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Retire the client-side registry refresh from changelog upload
The scrubber Lambda is the sole producer of the public registry.json,
reconciled from public bucket state on the S3 events every upload already
emits (elastic/docs-eng-team#688 Phase 3). Uploads now write YAML objects
only; RegistryBuilder and the private-manifest write path are removed, and
the amend end-to-end test exercises RegistryReconciler instead.
* Rewrite changelog registry docs for scrubber Lambda ownership
The registry docs still described the retired model: client-side refresh,
registry pass-through, pre-scrub ETags, a 1 h CloudFront TTL (caching is
disabled), and a refresh "skipped for --artifact-type changelog". Documents
the reconciler as sole producer, the public-object ETag, convergence
semantics, absent-vs-empty manifests, the reconcile message contract, and
the registry reconcile/verify operator commands (docs-eng-team#688 Phase 4).
* Align registry docs with the merged #3738 rework and #3760
The draft predated the #3738 review rework: RegistryReconciler is now BundleRegistryReconciler and reconciles the bundle/{product}/ tree only, the changelog/ pool manifests remain legacy client-authored pass-through (changelog bundle still enumerates pools through them, RFC #698 replaces that), the registry reconcile/verify operator CLI was dropped with #3741, and the scrubber now also maintains the shallow per-tree folder-to-token maps (consumer side: #3801). Infra bullets match the applied docs-infra#360 IAM (no registry-operator grant, no private ListBucket) and observability as it exists (metric stream to docs-o11y; alerts and runbook tracked in docs-eng-team#692).
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Changelog: add temporary migrate-from-web command
* Changelog: test and document migrate-from-web
* Changelog: migrate-from-web writes bundles only (no registry refresh)
Restacked on the scrubber-ownership chain: the scrubber Lambda owns the
public bundle/{product}/registry.json manifests and the shallow per-tree
maps (#3738), and the client-side refresh machinery is retired (#3760).
The migration command now writes YAML bundle objects only; the S3 events
those creates emit trigger the reconciliation that materializes the
manifests. Tests assert no registry.json is ever PUT.
* Changelog: migrate-from-web covers the scope table, --products narrows
Review feedback (#3794): drop the standing config surface. The former
config/migrate-from-web.yml becomes a checked-in table in the command
itself (MigrateFromWebScope.All) — temporary tooling state, added per
rollout wave and deleted with the command. A run covers every product
in the table by default; --products x,y,z narrows it for tests and
pilots. The page→product mapping stays explicit because bundle product
ids appear in no published metadata: page frontmatter carries the site
taxonomy, not bundle ids.
One product's failure no longer aborts the run: each product migrates
independently and the run fails at the end if any of them did.
* FileSystemFactory: extend the mock-temp scope workaround to Windows
MockFileSystem hardcodes its temp path on every OS (C:\temp on Windows,
unix-ified /temp elsewhere) instead of calling Path.GetTempPath(), while
AllowedSpecialFolder.Temp resolves the real temp. The existing workaround
only covered non-Windows, assuming the two coincide there — they don't:
GitHub runners' real temp is under the user profile, so the first test to
write through mockFs.Path.GetTempPath() on Windows (migrate-from-web's
bundle staging) failed scope validation. Add the inner mock's temp as an
explicit root unconditionally; drop once TestableIO#1454 ships.
* Scrubber Lambda owns the public changelog registry via state reconcile Phase 1 of elastic/docs-eng-team#688. The public registry.json was a log of upload operations (client-written, pass-through copied); every known consistency gap followed from that. The scrubber Lambda now derives it from the public bucket's actual state: registry = f(state), never f(event). - Extract the Lambda's top-level handler logic into testable classes in Elastic.Changelog: ScrubberProcessor (batch coalescing by key and group, object-level reconcile with post-write source validation) and RegistryReconciler (delimited/paginated group listing, ETag reuse with amends always recomputed, semantic idempotence, conditional PUT/DELETE with bounded jittered retries on 412/409, newer-schema refusal). Program.cs is now a thin adapter. - Retire the registry pass-through in the same deploy: registry-key events only schedule a group reconcile, so client-authored JSON no longer reaches the public bucket uninspected. - Add a producer (algorithm version) field to the manifest; a mismatch — including legacy pass-through manifests — forces a full metadata recompute and a write even when entries are identical. - Emit per-invocation reconcile metrics as CloudWatch EMF (the Phase 0 observability item that could only land with the reconciler). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add changelog registry reconcile and verify commands Phase 2 of elastic/docs-eng-team#688. The cutover/heal tooling for the Lambda-owned public registry: - `changelog registry reconcile` plans groups (one scope, or the union of both buckets so orphan public groups are covered) and sends one versioned, discriminated reconcile message per group to the scrubber queue — {kind, version, scope, group, correlation_id}, validated through ChangelogKeys on both ends. The CLI never mutates S3; the Lambda stays the public bucket's single writer. --dry-run prints the plan; the non-dry-run path asks for confirmation (--yes for CI). Each run stamps one correlation id and prints a ledger line per group. - On a reconcile message the Lambda performs a full group heal: object-level reconcile over the union of both buckets' listings (copy what's live, delete what isn't), then the group reconcile — recovering lost/DLQ-expired scrub events. Requires the new optional PRIVATE_BUCKET_NAME Lambda env var; malformed messages are rejected to the DLQ where the Phase 0 alarm surfaces them. - `changelog registry verify` is the read-only sibling and cutover gate: compares each public manifest against what a reconcile would write (same listing spec and entry rules by construction) and reports divergence as missing/stale/corrupt/object-divergent, with unsupported schemas reported distinctly. - Fix the manifest ETag wire format: the snake_case policy serialized the producer-side field as "e_tag" while consumers and the documented format read "etag" — recorded ETags were invisible to every consumer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Suppress CA1001 on ChangelogRegistryServiceTests Same suppression RegistryBuilderTests carries: xUnit owns the test class lifetime and TestDiagnosticsCollector needs no disposal in these tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Retire the client-side registry refresh from changelog upload The scrubber Lambda is the sole producer of the public registry.json, reconciled from public bucket state on the S3 events every upload already emits (elastic/docs-eng-team#688 Phase 3). Uploads now write YAML objects only; RegistryBuilder and the private-manifest write path are removed, and the amend end-to-end test exercises RegistryReconciler instead. * Rewrite changelog registry docs for scrubber Lambda ownership The registry docs still described the retired model: client-side refresh, registry pass-through, pre-scrub ETags, a 1 h CloudFront TTL (caching is disabled), and a refresh "skipped for --artifact-type changelog". Documents the reconciler as sole producer, the public-object ETag, convergence semantics, absent-vs-empty manifests, the reconcile message contract, and the registry reconcile/verify operator commands (docs-eng-team#688 Phase 4). * Align registry docs with the merged #3738 rework and #3760 The draft predated the #3738 review rework: RegistryReconciler is now BundleRegistryReconciler and reconciles the bundle/{product}/ tree only, the changelog/ pool manifests remain legacy client-authored pass-through (changelog bundle still enumerates pools through them, RFC #698 replaces that), the registry reconcile/verify operator CLI was dropped with #3741, and the scrubber now also maintains the shallow per-tree folder-to-token maps (consumer side: #3801). Infra bullets match the applied docs-infra#360 IAM (no registry-operator grant, no private ListBucket) and observability as it exists (metric stream to docs-o11y; alerts and runbook tracked in docs-eng-team#692). --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
TL;DR
The scrubber Lambda becomes the sole producer of the public
bundle/{product}/registry.jsonmanifests, recomputing them from what is actually in the public bucket (registry = f(state), neverf(event)). It also maintains two new shallow per-tree change maps for cache opt-out. The changelog pool manifests (changelog/{org}/{repo}/{branch}/registry.json) are not reconciled — they stay client-authored pass-through until #3760 retires them.Phase 1 of elastic/docs-eng-team#688. In the stack: base of #3760 (retire the client-side refresh) → #3761 (docs) / #3794 (migrate-from-web); sibling #3801 is the first consumer of the shallow maps (elastic/docs-eng-team#737).
Why
Today the public registry is written client-side against the private bucket and pass-through copied by the Lambda — a log of upload operations, not a description of the public bucket. Every known gap follows from that: ad-hoc uploads leave it stale (the 2026-07-22 invisible-bundle incident), deletions orphan entries, failed scrubs leave phantom entries, and the recorded ETag never matches what CDN consumers see.
How it works
bundle/{product}/group gets its manifest rebuilt from the public listing: entries with matching ETags are reused, the rest recomputed from the scrubbed YAMLs (amends always recomputed — their target depends on the parent). Empty group → conditional delete (absent ≠ empty for consumers). Optimistic concurrency throughout (If-Match/If-None-Match: *, bounded retries, batch-item failure on exhaustion). Aproducerfield versions the algorithm; any mismatch forces a full recompute, which is also how the legacy pass-through manifests get healed at cutover. Recorded ETags are now the public object's — finally usable by CDN consumers.bundle/registry.jsonandchangelog/registry.json, shaped{"<folder>": "<token>"}— one small conditional write per touched tree per batch. Consumers treat the token as an opaque change signal and bail out early on unchanged folders. Nothing consumes them in this PR (Changelog CDN fetchers: skip unchanged folders via shallow registry maps #3801 does).Program.csinto testable classes inElastic.Changelog(ScrubberProcessor,BundleRegistryReconciler,ShallowRegistryReconciler,ChangelogContentScrubber); per-invocation EMF metrics (source-generated contract);FakeS3test fake harvested from Add changelog registry state discovery and reconciliation commands #3717.Two deliberate deviations from the review — please confirm
The review asked to drop the per-product manifests. Full rationale in this comment; short version:
bundle/{product}/registry.jsonstays, reconciler-owned. It is the one place consumers genuinely enumerate: the{changelog}directive and external CDN consumers list a product's bundles over HTTP (no listing API), and date-based targets (serverless) are not derivable client-side. Dropping it without a consumer-protocol redesign would reintroduce the 2026-07-22 incident. The pool tree's per-group manifests — what the review's argument targeted — are indeed no longer reconciled.Deploy & rollback
Requires the IAM grants from elastic/docs-infra#360 (
s3:GetObject/s3:ListBucketon the public bucket) at deploy time. The CLI upload path is untouched in this phase (#3760 retires it); rollback = revert the Lambda deploy.Testing
Elastic.Changelog.Tests: 895/895 passing — object/group/shallow reconcile suites cover heal-missing-entry, drop-orphan, ETag reuse, amend recomputation, producer cutover, pagination and/-delimiter isolation, corrupt-manifest rebuild, empty-group conditional delete, conditional-write races and exhaustion, out-of-order and overlapping events, batch coalescing, and partial-batch failure marking. Scrubber Lambda publishes Native AOT with zero trim warnings. No CLI surface change (docs/cli-schema.jsonuntouched); docs follow in #3761 once this shape settles.History: original shape vs. the 2026-08-06 rework
The first iteration reconciled per-product/per-pool manifests in both trees and included group heal machinery for the pool tree. The review rejected the pool-tree inventory ("would just accumulate forever for little consumer value") and asked for shallow folder→ETag maps instead. The rework (7f4181c) scoped group reconciliation to the bundle tree, returned pool manifests to client-authored pass-through (mirrored verbatim, so #3760's cleanup deletes still propagate), and introduced the two shallow maps. A follow-up (b7fe622) renamed
RegistryReconciler→BundleRegistryReconcilerand fixed staleRegistry.csdoc comments to match.Part of elastic/docs-eng-team#688 (Phase 1).