dirigent-examples 0.15.0__py3-none-any.whl
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.
- dirigent_examples/__init__.py +22 -0
- dirigent_examples/py.typed +0 -0
- dirigent_examples/shelves/README.md +299 -0
- dirigent_examples/shelves/composition/README.md +18 -0
- dirigent_examples/shelves/composition/chained-instances.yaml +97 -0
- dirigent_examples/shelves/composition/composition-child.yaml +64 -0
- dirigent_examples/shelves/composition/composition-parent.yaml +89 -0
- dirigent_examples/shelves/connections.yaml +52 -0
- dirigent_examples/shelves/demo/README.md +19 -0
- dirigent_examples/shelves/demo/markdown-showcase.yaml +117 -0
- dirigent_examples/shelves/demo/params-showcase.yaml +92 -0
- dirigent_examples/shelves/demo/requires.yaml +65 -0
- dirigent_examples/shelves/demo/weekly-import-malawi.yaml +54 -0
- dirigent_examples/shelves/demo/weekly-import-nepal.yaml +75 -0
- dirigent_examples/shelves/docker/README.md +29 -0
- dirigent_examples/shelves/docker/docker-build-push.yaml +110 -0
- dirigent_examples/shelves/docker/docker-build-run.yaml +106 -0
- dirigent_examples/shelves/docker/docker-compose-database.yaml +124 -0
- dirigent_examples/shelves/docker/docker-compose-failing-up.yaml +83 -0
- dirigent_examples/shelves/docker/docker-compose-file.yaml +117 -0
- dirigent_examples/shelves/docker/docker-compose-profiles-env.yaml +133 -0
- dirigent_examples/shelves/docker/docker-compose-stack.yaml +70 -0
- dirigent_examples/shelves/docker/docker-hello.yaml +53 -0
- dirigent_examples/shelves/docker/docker-remote-daemon.yaml +92 -0
- dirigent_examples/shelves/docker/docker-run-failing-teardown.yaml +94 -0
- dirigent_examples/shelves/docker/docker-ticker.yaml +49 -0
- dirigent_examples/shelves/execute/README.md +16 -0
- dirigent_examples/shelves/execute/long-log.yaml +89 -0
- dirigent_examples/shelves/failure/README.md +20 -0
- dirigent_examples/shelves/failure/error-handler.yaml +89 -0
- dirigent_examples/shelves/failure/optional-step.yaml +82 -0
- dirigent_examples/shelves/failure/retries.yaml +75 -0
- dirigent_examples/shelves/failure/retry-budget.yaml +82 -0
- dirigent_examples/shelves/failure/step-timeout.yaml +96 -0
- dirigent_examples/shelves/git/README.md +32 -0
- dirigent_examples/shelves/git/git-checkout-build.yaml +125 -0
- dirigent_examples/shelves/git/git-checkout-compose.yaml +138 -0
- dirigent_examples/shelves/git/git-checkout-public.yaml +84 -0
- dirigent_examples/shelves/graph/README.md +22 -0
- dirigent_examples/shelves/graph/deep-chain.yaml +119 -0
- dirigent_examples/shelves/graph/fan-in.yaml +80 -0
- dirigent_examples/shelves/graph/fan-out.yaml +66 -0
- dirigent_examples/shelves/graph/linear.yaml +66 -0
- dirigent_examples/shelves/graph/parallel-branches.yaml +57 -0
- dirigent_examples/shelves/graph/parallel-sleep.yaml +55 -0
- dirigent_examples/shelves/graph/skip-diamond.yaml +105 -0
- dirigent_examples/shelves/graph/wide-fan.yaml +147 -0
- dirigent_examples/shelves/hello-world.yaml +30 -0
- dirigent_examples/shelves/open-data/README.md +67 -0
- dirigent_examples/shelves/open-data/feeds-composition.yaml +120 -0
- dirigent_examples/shelves/open-data/gdacs-disaster-updates.yaml +230 -0
- dirigent_examples/shelves/open-data/github-releases-relay.yaml +225 -0
- dirigent_examples/shelves/open-data/hdx-dataset-watch.yaml +211 -0
- dirigent_examples/shelves/open-data/kobo-submissions-to-csv.yaml +149 -0
- dirigent_examples/shelves/open-data/nominatim-geocode-facilities.yaml +169 -0
- dirigent_examples/shelves/open-data/odk-central-submissions.yaml +146 -0
- dirigent_examples/shelves/open-data/open-meteo-weekly-report.yaml +142 -0
- dirigent_examples/shelves/open-data/overpass-health-facilities.yaml +154 -0
- dirigent_examples/shelves/open-data/usgs-earthquakes-alert.yaml +208 -0
- dirigent_examples/shelves/open-data/who-gho-indicators-to-parquet.yaml +163 -0
- dirigent_examples/shelves/open-data/wikidata-country-reference.yaml +146 -0
- dirigent_examples/shelves/open-data/world-bank-population-trend.yaml +166 -0
- dirigent_examples/shelves/patterns/README.md +144 -0
- dirigent_examples/shelves/patterns/concurrency-queue.yaml +89 -0
- dirigent_examples/shelves/patterns/concurrency-replace.yaml +92 -0
- dirigent_examples/shelves/patterns/concurrency-skip.yaml +97 -0
- dirigent_examples/shelves/patterns/connections-referenced-vs-carried.yaml +140 -0
- dirigent_examples/shelves/patterns/deadline-on-a-sensor.yaml +106 -0
- dirigent_examples/shelves/patterns/fan-out-continue.yaml +88 -0
- dirigent_examples/shelves/patterns/fan-out-fail-fast.yaml +80 -0
- dirigent_examples/shelves/patterns/fan-out-from-params.yaml +84 -0
- dirigent_examples/shelves/patterns/fan-out-item-wise.yaml +111 -0
- dirigent_examples/shelves/patterns/fan-out-literal-list.yaml +81 -0
- dirigent_examples/shelves/patterns/fan-out-nested-objects.yaml +107 -0
- dirigent_examples/shelves/patterns/fan-out-then-join.yaml +86 -0
- dirigent_examples/shelves/patterns/log-levels.yaml +119 -0
- dirigent_examples/shelves/patterns/outputs-inline-vs-storage.yaml +140 -0
- dirigent_examples/shelves/patterns/params-every-type.yaml +259 -0
- dirigent_examples/shelves/patterns/params-validation-refuses.yaml +131 -0
- dirigent_examples/shelves/patterns/pipeline-run-child.yaml +96 -0
- dirigent_examples/shelves/patterns/pipeline-run-fire-and-forget.yaml +99 -0
- dirigent_examples/shelves/patterns/pipeline-run-strict.yaml +103 -0
- dirigent_examples/shelves/patterns/pipeline-run-wait.yaml +107 -0
- dirigent_examples/shelves/patterns/pipeline-run-with-params.yaml +124 -0
- dirigent_examples/shelves/patterns/poll-cadence.yaml +102 -0
- dirigent_examples/shelves/patterns/priority-layered.yaml +120 -0
- dirigent_examples/shelves/patterns/references-cheat-sheet.yaml +186 -0
- dirigent_examples/shelves/patterns/retry-budget-exhausted.yaml +92 -0
- dirigent_examples/shelves/patterns/retry-exponential-backoff.yaml +88 -0
- dirigent_examples/shelves/patterns/retry-only-transient.yaml +108 -0
- dirigent_examples/shelves/patterns/retry-with-jitter.yaml +102 -0
- dirigent_examples/shelves/patterns/rule-all-done.yaml +83 -0
- dirigent_examples/shelves/patterns/rule-all-success.yaml +79 -0
- dirigent_examples/shelves/patterns/rule-always.yaml +92 -0
- dirigent_examples/shelves/patterns/rule-one-failed.yaml +86 -0
- dirigent_examples/shelves/patterns/schedule-at-once.yaml +110 -0
- dirigent_examples/shelves/patterns/schedule-cron-timezone.yaml +121 -0
- dirigent_examples/shelves/patterns/schedule-interval.yaml +109 -0
- dirigent_examples/shelves/patterns/schedule-window-half-open.yaml +105 -0
- dirigent_examples/shelves/patterns/sensor-http-ready.yaml +121 -0
- dirigent_examples/shelves/patterns/sensor-storage-exists.yaml +134 -0
- dirigent_examples/shelves/patterns/step-names-and-keys.yaml +99 -0
- dirigent_examples/shelves/patterns/timeout-fails-the-step.yaml +94 -0
- dirigent_examples/shelves/patterns/timeout-skips-the-step.yaml +102 -0
- dirigent_examples/shelves/patterns/webhook-mapping-nested-payload.yaml +125 -0
- dirigent_examples/shelves/patterns/webhook-signed.yaml +144 -0
- dirigent_examples/shelves/preview/s3-parquet-to-ingestion.yaml +92 -0
- dirigent_examples/shelves/python/README.md +31 -0
- dirigent_examples/shelves/python/apply_and_run.py +52 -0
- dirigent_examples/shelves/python/ci_gate.py +76 -0
- dirigent_examples/shelves/python/connections.py +61 -0
- dirigent_examples/shelves/python/error_handling.py +84 -0
- dirigent_examples/shelves/python/follow_logs.py +39 -0
- dirigent_examples/shelves/python/list_and_filter.py +52 -0
- dirigent_examples/shelves/queues/README.md +59 -0
- dirigent_examples/shelves/queues/kafka-consume-then-transform.yaml +105 -0
- dirigent_examples/shelves/queues/kafka-produce-then-consume.yaml +124 -0
- dirigent_examples/shelves/queues/rabbitmq-consume-ack-on-success.yaml +102 -0
- dirigent_examples/shelves/queues/report-to-kafka.yaml +105 -0
- dirigent_examples/shelves/queues/report-to-rabbitmq.yaml +105 -0
- dirigent_examples/shelves/recipes/README.md +130 -0
- dirigent_examples/shelves/recipes/csv-header-rules.yaml +147 -0
- dirigent_examples/shelves/recipes/csv-to-ndjson.yaml +107 -0
- dirigent_examples/shelves/recipes/etl-csv-clean-validate-parquet.yaml +207 -0
- dirigent_examples/shelves/recipes/filter-by-predicate.yaml +116 -0
- dirigent_examples/shelves/recipes/filter-then-map-then-reduce.yaml +109 -0
- dirigent_examples/shelves/recipes/http-fetch-validate-post.yaml +142 -0
- dirigent_examples/shelves/recipes/http-follow-redirects.yaml +100 -0
- dirigent_examples/shelves/recipes/http-get-with-query.yaml +96 -0
- dirigent_examples/shelves/recipes/http-headers-and-auth-connection.yaml +110 -0
- dirigent_examples/shelves/recipes/http-post-file-from-storage.yaml +117 -0
- dirigent_examples/shelves/recipes/http-post-json-echo.yaml +105 -0
- dirigent_examples/shelves/recipes/http-post-report.yaml +183 -0
- dirigent_examples/shelves/recipes/http-save-body-to-storage.yaml +106 -0
- dirigent_examples/shelves/recipes/http-success-status-list.yaml +80 -0
- dirigent_examples/shelves/recipes/http-timeout-override.yaml +104 -0
- dirigent_examples/shelves/recipes/jq-dedupe-by-key.yaml +78 -0
- dirigent_examples/shelves/recipes/jq-defaults-and-nulls.yaml +91 -0
- dirigent_examples/shelves/recipes/jq-group-by-and-sum.yaml +74 -0
- dirigent_examples/shelves/recipes/jq-join-two-lists.yaml +77 -0
- dirigent_examples/shelves/recipes/jq-long-to-wide.yaml +76 -0
- dirigent_examples/shelves/recipes/jq-nested-to-flat.yaml +89 -0
- dirigent_examples/shelves/recipes/jq-pivot-wide-to-long.yaml +65 -0
- dirigent_examples/shelves/recipes/jq-running-totals.yaml +82 -0
- dirigent_examples/shelves/recipes/jq-string-cleaning.yaml +88 -0
- dirigent_examples/shelves/recipes/jq-top-n.yaml +85 -0
- dirigent_examples/shelves/recipes/jq-validate-in-jq-vs-schema.yaml +124 -0
- dirigent_examples/shelves/recipes/jq-window-dates.yaml +82 -0
- dirigent_examples/shelves/recipes/json-to-csv-flattening.yaml +141 -0
- dirigent_examples/shelves/recipes/large-output-to-storage.yaml +134 -0
- dirigent_examples/shelves/recipes/map-enrich-with-lookup.yaml +96 -0
- dirigent_examples/shelves/recipes/ndjson-to-parquet.yaml +130 -0
- dirigent_examples/shelves/recipes/pagination-by-fan-out.yaml +115 -0
- dirigent_examples/shelves/recipes/parquet-round-trip-types.yaml +163 -0
- dirigent_examples/shelves/recipes/reconcile-two-sources.yaml +159 -0
- dirigent_examples/shelves/recipes/report-built-in.yaml +72 -0
- dirigent_examples/shelves/recipes/report-daily-digest.yaml +186 -0
- dirigent_examples/shelves/recipes/report-to-file.yaml +131 -0
- dirigent_examples/shelves/recipes/report-to-webhook.yaml +136 -0
- dirigent_examples/shelves/recipes/schema-carried.yaml +112 -0
- dirigent_examples/shelves/recipes/schema-formats.yaml +107 -0
- dirigent_examples/shelves/recipes/schema-referenced.yaml +86 -0
- dirigent_examples/shelves/recipes/schema-refuses-then-rule.yaml +127 -0
- dirigent_examples/shelves/recipes/storage-copy-dated-archive.yaml +114 -0
- dirigent_examples/shelves/recipes/storage-exists-gate.yaml +127 -0
- dirigent_examples/shelves/recipes/storage-manifest-of-a-fan-out.yaml +104 -0
- dirigent_examples/shelves/recipes/storage-write-then-read.yaml +119 -0
- dirigent_examples/shelves/recipes/webhook-post-hmac.yaml +132 -0
- dirigent_examples/shelves/recipes/webhook-post-summary.yaml +142 -0
- dirigent_examples/shelves/s3/README.md +34 -0
- dirigent_examples/shelves/s3/report-to-s3.yaml +93 -0
- dirigent_examples/shelves/s3/s3-copy-and-verify.yaml +105 -0
- dirigent_examples/shelves/s3/s3-csv-report.yaml +87 -0
- dirigent_examples/shelves/s3/s3-parquet-report.yaml +77 -0
- dirigent_examples/shelves/s3/s3-round-trip.yaml +125 -0
- dirigent_examples/shelves/schemas/README.md +36 -0
- dirigent_examples/shelves/schemas/echo-reading.json +18 -0
- dirigent_examples/shelves/schemas/ou-record.json +13 -0
- dirigent_examples/shelves/schemas/station-reading.json +13 -0
- dirigent_examples/shelves/sensors/README.md +16 -0
- dirigent_examples/shelves/sensors/sensor-gate.yaml +65 -0
- dirigent_examples/shelves/sensors/time-window.yaml +61 -0
- dirigent_examples/shelves/sql/README.md +52 -0
- dirigent_examples/shelves/sql/duckdb-parquet-to-report.yaml +146 -0
- dirigent_examples/shelves/sql/sql-postgres-readonly.yaml +111 -0
- dirigent_examples/shelves/sql/sql-query-to-storage.yaml +85 -0
- dirigent_examples/shelves/sql/sql-sqlite-roundtrip.yaml +114 -0
- dirigent_examples/shelves/sql/warehouse.sql +42 -0
- dirigent_examples/shelves/transform/README.md +36 -0
- dirigent_examples/shelves/transform/csv-report.yaml +55 -0
- dirigent_examples/shelves/transform/jq-filter-and-map.yaml +70 -0
- dirigent_examples/shelves/transform/jq-group-and-aggregate.yaml +70 -0
- dirigent_examples/shelves/transform/jq-join-two-sources.yaml +98 -0
- dirigent_examples/shelves/transform/jq-reshape.yaml +91 -0
- dirigent_examples/shelves/transform/jq-stream-through-storage.yaml +112 -0
- dirigent_examples/shelves/transform/ndjson-round-trip.yaml +56 -0
- dirigent_examples/shelves/transform/parquet-round-trip.yaml +68 -0
- dirigent_examples/shelves/transform/std-convert-fan-out.yaml +142 -0
- dirigent_examples/shelves/transform/xml-feed-to-ndjson.yaml +116 -0
- dirigent_examples/shelves/transform/yaml-config-to-json.yaml +104 -0
- dirigent_examples/shelves/triggers/README.md +45 -0
- dirigent_examples/shelves/triggers/at-one-time.yaml +78 -0
- dirigent_examples/shelves/triggers/cron-nightly.yaml +79 -0
- dirigent_examples/shelves/triggers/cron-windowed.yaml +86 -0
- dirigent_examples/shelves/triggers/document-nightly.yaml +80 -0
- dirigent_examples/shelves/triggers/interval-rolling.yaml +88 -0
- dirigent_examples/shelves/triggers/managed-and-manual.yaml +109 -0
- dirigent_examples/shelves/triggers/webhook-trigger.yaml +75 -0
- dirigent_examples/shelves/validate/README.md +31 -0
- dirigent_examples/shelves/validate/expects-a-shape.yaml +56 -0
- dirigent_examples/shelves/validate/the-shape-is-wrong.yaml +46 -0
- dirigent_examples-0.15.0.dist-info/METADATA +21 -0
- dirigent_examples-0.15.0.dist-info/RECORD +216 -0
- dirigent_examples-0.15.0.dist-info/WHEEL +4 -0
- dirigent_examples-0.15.0.dist-info/entry_points.txt +3 -0
- dirigent_examples-0.15.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Two systems that should agree, and a run that says exactly how they do not.
|
|
2
|
+
#
|
|
3
|
+
# In: the same day's totals from a source of record and from a warehouse, each with rows
|
|
4
|
+
# the other does not have.
|
|
5
|
+
# Out: {matched, only_in_source, only_in_target, differing, totals} and a run that fails
|
|
6
|
+
# when the disagreement is bigger than the tolerance.
|
|
7
|
+
#
|
|
8
|
+
# A reconciliation has exactly four buckets, and a report that does not name all four is not
|
|
9
|
+
# a reconciliation:
|
|
10
|
+
#
|
|
11
|
+
# in both, equal the boring bucket, and the one worth counting so the others have a
|
|
12
|
+
# denominator.
|
|
13
|
+
# in both, different the interesting bucket. Each row carries both values and their
|
|
14
|
+
# difference, because "17 rows differ" cannot be investigated.
|
|
15
|
+
# only in the source something that has not arrived yet, or a load that dropped it.
|
|
16
|
+
# only in the target something deleted upstream, or loaded twice under a changed key.
|
|
17
|
+
#
|
|
18
|
+
# The comparison is by key, so both sides are indexed first with INDEX and the key set is
|
|
19
|
+
# the union of the two -- not the source's keys, which would make anything extra in the
|
|
20
|
+
# target invisible.
|
|
21
|
+
#
|
|
22
|
+
# Floating point is the trap in the equality test. Two systems that both computed 0.1 + 0.2
|
|
23
|
+
# do not agree on it, so the comparison is a tolerance rather than ==, and the tolerance is
|
|
24
|
+
# a parameter because what counts as equal is a property of the data and not of the code.
|
|
25
|
+
#
|
|
26
|
+
# The verdict is the last decision: a difference that is within tolerance is not a
|
|
27
|
+
# difference, a small number of differing rows is a warning somebody reads, and past the
|
|
28
|
+
# threshold the run fails so that a schedule stops rather than reporting quietly for a week.
|
|
29
|
+
#
|
|
30
|
+
# To change it: -p max_differing=5 lets this run pass with its three differing rows; the
|
|
31
|
+
# default of 2 is below that, so the default run fails on purpose.
|
|
32
|
+
#
|
|
33
|
+
# dg run --local examples/recipes/reconcile-two-sources.yaml # fails, on purpose
|
|
34
|
+
# dg run --local examples/recipes/reconcile-two-sources.yaml -p max_differing=5
|
|
35
|
+
|
|
36
|
+
format: dirigent/v1
|
|
37
|
+
kind: pipeline
|
|
38
|
+
code: reconcile-two-sources
|
|
39
|
+
name: Reconcile two sources
|
|
40
|
+
description: Compare two systems' totals by key with a tolerance, report the four buckets, and fail when too many rows disagree.
|
|
41
|
+
|
|
42
|
+
tags: [recipes, transform]
|
|
43
|
+
|
|
44
|
+
requires:
|
|
45
|
+
blocks:
|
|
46
|
+
- value.const
|
|
47
|
+
- transform.jq
|
|
48
|
+
|
|
49
|
+
params:
|
|
50
|
+
type: object
|
|
51
|
+
properties:
|
|
52
|
+
day:
|
|
53
|
+
type: string
|
|
54
|
+
format: date
|
|
55
|
+
default: "2026-01-01"
|
|
56
|
+
description: The day both sides are reporting on.
|
|
57
|
+
tolerance:
|
|
58
|
+
type: number
|
|
59
|
+
minimum: 0
|
|
60
|
+
default: 0.01
|
|
61
|
+
description: How far two values may differ and still count as equal.
|
|
62
|
+
max_differing:
|
|
63
|
+
type: integer
|
|
64
|
+
minimum: 0
|
|
65
|
+
default: 2
|
|
66
|
+
description: How many differing rows are tolerated before the run fails.
|
|
67
|
+
|
|
68
|
+
steps:
|
|
69
|
+
source:
|
|
70
|
+
block: value.const
|
|
71
|
+
config:
|
|
72
|
+
value:
|
|
73
|
+
- { key: east/pump, total: 300.0 }
|
|
74
|
+
- { key: east/valve, total: 250.0 }
|
|
75
|
+
- { key: west/pump, total: 100.0 }
|
|
76
|
+
- { key: west/valve, total: 100.0 }
|
|
77
|
+
# In the source and not in the warehouse: not loaded yet, or dropped.
|
|
78
|
+
- { key: north/pump, total: 200.0 }
|
|
79
|
+
- { key: south/pump, total: 42.5 }
|
|
80
|
+
|
|
81
|
+
target:
|
|
82
|
+
block: value.const
|
|
83
|
+
config:
|
|
84
|
+
value:
|
|
85
|
+
- { key: east/pump, total: 300.0 }
|
|
86
|
+
# Differs by more than the tolerance.
|
|
87
|
+
- { key: east/valve, total: 247.5 }
|
|
88
|
+
# Differs by less: floating-point noise, not a disagreement.
|
|
89
|
+
- { key: west/pump, total: 100.000000001 }
|
|
90
|
+
- { key: west/valve, total: 90.0 }
|
|
91
|
+
- { key: south/pump, total: 41.0 }
|
|
92
|
+
# In the warehouse and not in the source: deleted upstream, or loaded twice.
|
|
93
|
+
- { key: east/gasket, total: 12.0 }
|
|
94
|
+
|
|
95
|
+
compare:
|
|
96
|
+
block: transform.jq
|
|
97
|
+
depends_on: [source, target]
|
|
98
|
+
config:
|
|
99
|
+
input:
|
|
100
|
+
source: ${steps.source.output.value}
|
|
101
|
+
target: ${steps.target.output.value}
|
|
102
|
+
tolerance: ${params.tolerance}
|
|
103
|
+
day: ${params.day}
|
|
104
|
+
program: |
|
|
105
|
+
. as {$source, $target, $tolerance, $day}
|
|
106
|
+
| ($source | INDEX(.key)) as $left
|
|
107
|
+
| ($target | INDEX(.key)) as $right
|
|
108
|
+
# The union of both key sets: the source's keys alone would hide anything extra in
|
|
109
|
+
# the target.
|
|
110
|
+
| (([$source[].key] + [$target[].key]) | unique) as $keys
|
|
111
|
+
| [$keys[]
|
|
112
|
+
| . as $key
|
|
113
|
+
| {key: $key,
|
|
114
|
+
source: ($left[$key].total // null),
|
|
115
|
+
target: ($right[$key].total // null)}
|
|
116
|
+
| . + {bucket: (if .source == null then "only_in_target"
|
|
117
|
+
elif .target == null then "only_in_source"
|
|
118
|
+
# A tolerance, not ==: two systems that both computed the same
|
|
119
|
+
# sum in floating point do not agree on its last bits.
|
|
120
|
+
elif ((.source - .target) | fabs) <= $tolerance then "matched"
|
|
121
|
+
else "differing" end),
|
|
122
|
+
difference: (if .source == null or .target == null then null
|
|
123
|
+
else ((.source - .target) * 1000 | round / 1000) end)}]
|
|
124
|
+
as $rows
|
|
125
|
+
| {day: $day,
|
|
126
|
+
tolerance: $tolerance,
|
|
127
|
+
matched: [$rows[] | select(.bucket == "matched") | .key],
|
|
128
|
+
only_in_source: [$rows[] | select(.bucket == "only_in_source") | .key],
|
|
129
|
+
only_in_target: [$rows[] | select(.bucket == "only_in_target") | .key],
|
|
130
|
+
# Both values and the difference, because a count of differing rows cannot be
|
|
131
|
+
# investigated by anybody.
|
|
132
|
+
differing: [$rows[] | select(.bucket == "differing")
|
|
133
|
+
| {key, source, target, difference}],
|
|
134
|
+
totals: {source: ([$source[].total] | add),
|
|
135
|
+
target: ([$target[].total] | add),
|
|
136
|
+
keys: ($keys | length)}}
|
|
137
|
+
|
|
138
|
+
verdict:
|
|
139
|
+
block: transform.jq
|
|
140
|
+
depends_on: [compare]
|
|
141
|
+
config:
|
|
142
|
+
input:
|
|
143
|
+
report: ${steps.compare.output.value}
|
|
144
|
+
max_differing: ${params.max_differing}
|
|
145
|
+
program: |
|
|
146
|
+
. as {$report, $max_differing}
|
|
147
|
+
| ($report.differing | length) as $differing
|
|
148
|
+
# Past the threshold the run fails, so a schedule stops instead of reporting a
|
|
149
|
+
# widening gap quietly every morning.
|
|
150
|
+
| if $differing > $max_differing
|
|
151
|
+
then error("\($differing) rows differ beyond the tolerance, and \($max_differing) "
|
|
152
|
+
+ "is the limit: \([$report.differing[].key] | join(", "))")
|
|
153
|
+
else {day: $report.day,
|
|
154
|
+
differing: $differing,
|
|
155
|
+
matched: ($report.matched | length),
|
|
156
|
+
only_in_source: $report.only_in_source,
|
|
157
|
+
only_in_target: $report.only_in_target,
|
|
158
|
+
gap: (($report.totals.source - $report.totals.target) * 100 | round / 100)}
|
|
159
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Ask for a run's report and write no template at all.
|
|
2
|
+
#
|
|
3
|
+
# In: five sales rows, inline.
|
|
4
|
+
# Out: a total per region, and the run's own report document -- the built-in one, because
|
|
5
|
+
# the report: section is empty.
|
|
6
|
+
#
|
|
7
|
+
# `report: {}` is the whole of the opt-in. A document with no report: section renders
|
|
8
|
+
# nothing; an empty section renders the page the engine ships:
|
|
9
|
+
#
|
|
10
|
+
# a title the pipeline's name, or its code when it has none, and the run's status
|
|
11
|
+
# the facts run id, pipeline and version, trigger, started, finished, duration, when the
|
|
12
|
+
# page was rendered, and the data window when the run has one
|
|
13
|
+
# the steps a row per step: outcome, attempts, how long it took, how large its output was,
|
|
14
|
+
# and how many warnings it logged, which a succeeded step can still have
|
|
15
|
+
# the items the elements that failed, when a fan-out step had any, each with the step it
|
|
16
|
+
# failed at and the error
|
|
17
|
+
# the error the run's own error, or a cancellation's reason, when it ended badly
|
|
18
|
+
#
|
|
19
|
+
# That page is written by the engine rather than by a step, which is the point of it: a run
|
|
20
|
+
# that was cancelled, or whose worker died, has no step left to write anything, and those
|
|
21
|
+
# are the runs whose account is worth the most. It is rendered in the transaction that
|
|
22
|
+
# settles the run, and rewritten in place if a retry reopens the run and it settles again.
|
|
23
|
+
#
|
|
24
|
+
# The two steps are here to give the page something to tabulate, and they are the smallest
|
|
25
|
+
# pair that does: a literal, and one jq program over it.
|
|
26
|
+
#
|
|
27
|
+
# To change it: write a `template:` under report: and the page is yours instead --
|
|
28
|
+
# http-post-report.yaml is that document, and docs/reports.md lists every fact a template
|
|
29
|
+
# may read.
|
|
30
|
+
#
|
|
31
|
+
# dg run --local examples/recipes/report-built-in.yaml
|
|
32
|
+
# dg apply examples/recipes/report-built-in.yaml
|
|
33
|
+
# dg run report-built-in --watch
|
|
34
|
+
# dg runs report RUN_ID --markdown
|
|
35
|
+
|
|
36
|
+
format: dirigent/v1
|
|
37
|
+
kind: pipeline
|
|
38
|
+
code: report-built-in
|
|
39
|
+
name: The built-in report
|
|
40
|
+
description: The smallest pipeline that renders a report document, with an empty report section and no template of its own.
|
|
41
|
+
|
|
42
|
+
tags: [recipes, transform, report, starter]
|
|
43
|
+
|
|
44
|
+
requires:
|
|
45
|
+
blocks:
|
|
46
|
+
- value.const
|
|
47
|
+
- transform.jq
|
|
48
|
+
|
|
49
|
+
steps:
|
|
50
|
+
rows:
|
|
51
|
+
block: value.const
|
|
52
|
+
config:
|
|
53
|
+
value:
|
|
54
|
+
- { region: east, revenue: 300 }
|
|
55
|
+
- { region: east, revenue: 250 }
|
|
56
|
+
- { region: west, revenue: 100 }
|
|
57
|
+
- { region: west, revenue: 100 }
|
|
58
|
+
- { region: north, revenue: 200 }
|
|
59
|
+
|
|
60
|
+
totals:
|
|
61
|
+
block: transform.jq
|
|
62
|
+
depends_on: [rows]
|
|
63
|
+
config:
|
|
64
|
+
input: ${steps.rows.output.value}
|
|
65
|
+
program: |
|
|
66
|
+
{regions: (group_by(.region)
|
|
67
|
+
| map({region: .[0].region,
|
|
68
|
+
rows: length,
|
|
69
|
+
revenue: (map(.revenue) | add)})),
|
|
70
|
+
revenue: (map(.revenue) | add)}
|
|
71
|
+
|
|
72
|
+
report: {}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# A daily digest: fetch, aggregate, write a csv somebody opens, and post the summary.
|
|
2
|
+
#
|
|
3
|
+
# In: a day, as a parameter. The readings are generated so the recipe runs anywhere; the
|
|
4
|
+
# one HTTP call is the delivery at the end.
|
|
5
|
+
# Out: a csv artifact per region in the run's scratch space, a markdown-ish digest line, and
|
|
6
|
+
# a delivered notification whose body carries the numbers.
|
|
7
|
+
#
|
|
8
|
+
# This is the shape most reporting pipelines end up with, and the reason each piece is
|
|
9
|
+
# separate:
|
|
10
|
+
#
|
|
11
|
+
# window the day's boundaries are computed once, from the parameter and not from the
|
|
12
|
+
# clock, so a rerun for last Tuesday reports last Tuesday. A pipeline that
|
|
13
|
+
# reads `now` cannot be rerun, and being rerunnable is most of what makes an
|
|
14
|
+
# incident survivable.
|
|
15
|
+
# readings stands in for the source read. Replacing it with an http.request whose body
|
|
16
|
+
# this step reads changes this step and nothing below it.
|
|
17
|
+
# aggregate the numbers, computed once, in one place. Everything below reads them rather
|
|
18
|
+
# than recomputing, so the csv, the digest line and the notification can never
|
|
19
|
+
# disagree.
|
|
20
|
+
# rows_file the per-region numbers as an object in the run's scratch space. A conversion
|
|
21
|
+
# reads a URI and writes a URI, so the value goes out through storage.write
|
|
22
|
+
# before the codec sees it.
|
|
23
|
+
# csv the artifact somebody actually opens, converted from that object under a
|
|
24
|
+
# dated name.
|
|
25
|
+
# digest the human sentence, derived from the aggregate.
|
|
26
|
+
# notify the delivery, on rule all_done so a failed report still says something.
|
|
27
|
+
#
|
|
28
|
+
# A digest has to say what it does not know. The empty_regions field is there because a
|
|
29
|
+
# region that reported nothing looks exactly like a region that reported zero once the
|
|
30
|
+
# averages are taken, and only the pipeline knows the difference.
|
|
31
|
+
#
|
|
32
|
+
# To change it: -p day=2026-02-03 reruns for another day; -p regions='["east"]' narrows it.
|
|
33
|
+
#
|
|
34
|
+
# dg run --local examples/recipes/report-daily-digest.yaml
|
|
35
|
+
# dg run --local examples/recipes/report-daily-digest.yaml -p day=2026-02-03
|
|
36
|
+
|
|
37
|
+
format: dirigent/v1
|
|
38
|
+
kind: pipeline
|
|
39
|
+
code: report-daily-digest
|
|
40
|
+
name: A daily digest report
|
|
41
|
+
description: Compute a day's numbers once, write them to storage and convert them into a csv artifact, derive a digest line, and post the summary to a receiver.
|
|
42
|
+
|
|
43
|
+
tags: [recipes, storage, transform, webhook]
|
|
44
|
+
|
|
45
|
+
requires:
|
|
46
|
+
blocks:
|
|
47
|
+
- transform.jq
|
|
48
|
+
- storage.write
|
|
49
|
+
- convert.std
|
|
50
|
+
- webhook.post
|
|
51
|
+
|
|
52
|
+
connections:
|
|
53
|
+
echo-webhook:
|
|
54
|
+
kind: http
|
|
55
|
+
config:
|
|
56
|
+
base_url: https://postman-echo.com
|
|
57
|
+
timeout: 30s
|
|
58
|
+
health_path: /get
|
|
59
|
+
|
|
60
|
+
params:
|
|
61
|
+
type: object
|
|
62
|
+
properties:
|
|
63
|
+
day:
|
|
64
|
+
type: string
|
|
65
|
+
format: date
|
|
66
|
+
default: "2026-01-01"
|
|
67
|
+
description: The day being reported on; every date in the run comes from here.
|
|
68
|
+
regions:
|
|
69
|
+
type: array
|
|
70
|
+
default: [east, west, north]
|
|
71
|
+
items:
|
|
72
|
+
type: string
|
|
73
|
+
description: The regions the digest covers, including any that report nothing.
|
|
74
|
+
|
|
75
|
+
steps:
|
|
76
|
+
window:
|
|
77
|
+
block: transform.jq
|
|
78
|
+
config:
|
|
79
|
+
input: ${params.day}
|
|
80
|
+
program: |
|
|
81
|
+
. as $day
|
|
82
|
+
| ($day + "T00:00:00Z" | fromdateiso8601) as $from
|
|
83
|
+
| {day: $day,
|
|
84
|
+
start: ($from | todate),
|
|
85
|
+
end: ($from + 86400 | todate),
|
|
86
|
+
iso_week: ($from | strftime("%G-W%V"))}
|
|
87
|
+
|
|
88
|
+
readings:
|
|
89
|
+
block: transform.jq
|
|
90
|
+
depends_on: [window]
|
|
91
|
+
config:
|
|
92
|
+
input: ${steps.window.output.value}
|
|
93
|
+
# Stands in for the source read: an http.request, or a storage.read of yesterday's drop.
|
|
94
|
+
# north is absent on purpose, so the digest has something to not know.
|
|
95
|
+
program: |
|
|
96
|
+
. as {$day}
|
|
97
|
+
| [range(24) as $hour
|
|
98
|
+
| ["east", "west"] as $regions
|
|
99
|
+
| $regions[]
|
|
100
|
+
| {region: ., day: $day,
|
|
101
|
+
hour: $hour,
|
|
102
|
+
celsius: ((($hour * 3) % 19) - 6),
|
|
103
|
+
station: "\(.)-st-\($hour % 3)"}]
|
|
104
|
+
|
|
105
|
+
aggregate:
|
|
106
|
+
block: transform.jq
|
|
107
|
+
depends_on: [readings, window]
|
|
108
|
+
config:
|
|
109
|
+
input:
|
|
110
|
+
rows: ${steps.readings.output.value}
|
|
111
|
+
window: ${steps.window.output.value}
|
|
112
|
+
regions: ${params.regions}
|
|
113
|
+
program: |
|
|
114
|
+
. as {$rows, $window, $regions}
|
|
115
|
+
| ($rows | group_by(.region)
|
|
116
|
+
| map({region: .[0].region,
|
|
117
|
+
readings: length,
|
|
118
|
+
stations: ([.[].station] | unique | length),
|
|
119
|
+
mean_celsius: ((map(.celsius) | add) / length * 10 | round / 10),
|
|
120
|
+
min_celsius: (map(.celsius) | min),
|
|
121
|
+
max_celsius: (map(.celsius) | max)})) as $per_region
|
|
122
|
+
| {day: $window.day,
|
|
123
|
+
iso_week: $window.iso_week,
|
|
124
|
+
regions: $per_region,
|
|
125
|
+
total_readings: ($rows | length),
|
|
126
|
+
# A region that reported nothing is not a region that reported zero, and only
|
|
127
|
+
# this pipeline knows which happened.
|
|
128
|
+
empty_regions: ($regions - [$per_region[].region])}
|
|
129
|
+
|
|
130
|
+
rows_file:
|
|
131
|
+
block: storage.write
|
|
132
|
+
depends_on: [aggregate]
|
|
133
|
+
config:
|
|
134
|
+
target: ${run.scratch}/digest-${params.day}.json
|
|
135
|
+
value: ${steps.aggregate.output.value.regions}
|
|
136
|
+
|
|
137
|
+
csv:
|
|
138
|
+
block: convert.std
|
|
139
|
+
depends_on: [rows_file]
|
|
140
|
+
config:
|
|
141
|
+
source: ${steps.rows_file.output.uri}
|
|
142
|
+
target: ${run.scratch}/digest-${params.day}.csv
|
|
143
|
+
from: json
|
|
144
|
+
to: csv
|
|
145
|
+
|
|
146
|
+
digest:
|
|
147
|
+
block: transform.jq
|
|
148
|
+
depends_on: [aggregate, csv]
|
|
149
|
+
config:
|
|
150
|
+
input:
|
|
151
|
+
summary: ${steps.aggregate.output.value}
|
|
152
|
+
file: ${steps.csv.output.target}
|
|
153
|
+
program: |
|
|
154
|
+
.summary as $s
|
|
155
|
+
| {day: $s.day,
|
|
156
|
+
iso_week: $s.iso_week,
|
|
157
|
+
file: .file,
|
|
158
|
+
# Derived from the numbers, so it cannot disagree with them.
|
|
159
|
+
text: "\($s.day) (\($s.iso_week)): \($s.total_readings) readings across "
|
|
160
|
+
+ "\($s.regions | length) regions"
|
|
161
|
+
+ (if ($s.empty_regions | length) > 0
|
|
162
|
+
then "; no data from \($s.empty_regions | join(", "))"
|
|
163
|
+
else "" end),
|
|
164
|
+
lines: [$s.regions[]
|
|
165
|
+
| "\(.region): \(.readings) readings, mean \(.mean_celsius)C "
|
|
166
|
+
+ "(\(.min_celsius) to \(.max_celsius))"]}
|
|
167
|
+
|
|
168
|
+
notify:
|
|
169
|
+
block: webhook.post
|
|
170
|
+
depends_on: [digest]
|
|
171
|
+
rule: all_done
|
|
172
|
+
config:
|
|
173
|
+
connection: echo-webhook
|
|
174
|
+
path: /post
|
|
175
|
+
body: ${steps.digest.output.value}
|
|
176
|
+
success_status: [200, 202, 204]
|
|
177
|
+
|
|
178
|
+
delivered:
|
|
179
|
+
block: transform.jq
|
|
180
|
+
depends_on: [notify]
|
|
181
|
+
config:
|
|
182
|
+
input:
|
|
183
|
+
status: ${steps.notify.output.status}
|
|
184
|
+
echoed: ${steps.notify.output.json_body}
|
|
185
|
+
program: |
|
|
186
|
+
{status, text: .echoed.json.text, lines: .echoed.json.lines, file: .echoed.json.file}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Render a page of markdown and put it in a file, then read the file back and log it.
|
|
2
|
+
#
|
|
3
|
+
# In: three readings written inline.
|
|
4
|
+
# Out: a markdown page at ${run.scratch}/report.md, and its text on the run's own timeline.
|
|
5
|
+
#
|
|
6
|
+
# report.render renders text and hands it on; it writes nothing itself. Sending it
|
|
7
|
+
# somewhere is a step of its own, and which step that is decides where the page goes -- a
|
|
8
|
+
# file here, a bucket in examples/s3/report-to-s3.yaml, a queue in the two under
|
|
9
|
+
# examples/queues/. The template does not change between them.
|
|
10
|
+
#
|
|
11
|
+
# Six hops, and what each one hands on:
|
|
12
|
+
# readings value.const, the rows a real pipeline would have fetched.
|
|
13
|
+
# summary transform.jq, the numbers the page is about, computed before anything is
|
|
14
|
+
# worded. A template that does arithmetic is a template nobody can test.
|
|
15
|
+
# page report.render, the markdown. Output: text, content_type, text_bytes.
|
|
16
|
+
# save storage.write, the text to a URI. Output: uri, bytes_written, content_type.
|
|
17
|
+
# back storage.read, the same URI. It is text/markdown, so the object comes back as
|
|
18
|
+
# `text` rather than as `value`.
|
|
19
|
+
# show log.write, the page onto the run's timeline, which is the smallest sink there
|
|
20
|
+
# is and the easiest way to see that the round trip worked.
|
|
21
|
+
#
|
|
22
|
+
# THE TEMPLATE IS JINJA, and `values` is everything it may read: names it was not given
|
|
23
|
+
# render as the empty string rather than failing the step, so a page missing a fact is still
|
|
24
|
+
# a page. It is compiled when the document is applied, so a syntax error is refused there
|
|
25
|
+
# rather than at the first run. docs/reports.md is the family's home; the same language
|
|
26
|
+
# writes a run's own `report:` section.
|
|
27
|
+
#
|
|
28
|
+
# WHY THE CONTENT TYPE IS SAID TWICE. report.render says what the text is and storage.write
|
|
29
|
+
# records it, so the object carries its type on a backend that can hold one and is guessed
|
|
30
|
+
# from the `.md` extension on one that cannot. storage.read resolves it the same way round:
|
|
31
|
+
# an override, else what the backend stored, else the extension, else bytes it refuses to
|
|
32
|
+
# decode.
|
|
33
|
+
#
|
|
34
|
+
# TO MAKE IT YOURS: change the template, and change where `save` points. Nothing else moves.
|
|
35
|
+
#
|
|
36
|
+
# dg run --local examples/recipes/report-to-file.yaml
|
|
37
|
+
# dg run --local examples/recipes/report-to-file.yaml -p day=2026-02-01
|
|
38
|
+
|
|
39
|
+
format: dirigent/v1
|
|
40
|
+
kind: pipeline
|
|
41
|
+
code: report-to-file
|
|
42
|
+
name: Render a report and write it to a file
|
|
43
|
+
description: Render a markdown page from a Jinja template, write it to the run's scratch space, and read it back.
|
|
44
|
+
|
|
45
|
+
tags: [recipes, report, storage, transform, starter]
|
|
46
|
+
|
|
47
|
+
requires:
|
|
48
|
+
blocks:
|
|
49
|
+
- value.const
|
|
50
|
+
- transform.jq
|
|
51
|
+
- report.render
|
|
52
|
+
- storage.write
|
|
53
|
+
- storage.read
|
|
54
|
+
- log.write
|
|
55
|
+
|
|
56
|
+
params:
|
|
57
|
+
type: object
|
|
58
|
+
properties:
|
|
59
|
+
day:
|
|
60
|
+
type: string
|
|
61
|
+
format: date
|
|
62
|
+
default: "2026-01-01"
|
|
63
|
+
description: The day the report is about.
|
|
64
|
+
|
|
65
|
+
steps:
|
|
66
|
+
readings:
|
|
67
|
+
block: value.const
|
|
68
|
+
config:
|
|
69
|
+
value:
|
|
70
|
+
- {station: st-1, region: east, celsius: 4.5}
|
|
71
|
+
- {station: st-3, region: west, celsius: 1.2}
|
|
72
|
+
- {station: st-4, region: north, celsius: -3.4}
|
|
73
|
+
|
|
74
|
+
summary:
|
|
75
|
+
block: transform.jq
|
|
76
|
+
depends_on: [readings]
|
|
77
|
+
config:
|
|
78
|
+
input: ${steps.readings.output.value}
|
|
79
|
+
# Every number the page shows is computed here, where it can be asserted on.
|
|
80
|
+
program: |
|
|
81
|
+
{stations: length,
|
|
82
|
+
coldest: (min_by(.celsius) | .station),
|
|
83
|
+
below_zero: [.[] | select(.celsius < 0) | .station],
|
|
84
|
+
rows: [.[] | {station, region, celsius}]}
|
|
85
|
+
|
|
86
|
+
page:
|
|
87
|
+
block: report.render
|
|
88
|
+
depends_on: [summary]
|
|
89
|
+
config:
|
|
90
|
+
# What the template may read, and the only thing it may read.
|
|
91
|
+
values:
|
|
92
|
+
day: ${params.day}
|
|
93
|
+
summary: ${steps.summary.output.value}
|
|
94
|
+
content_type: text/markdown
|
|
95
|
+
template: |
|
|
96
|
+
# Readings for {{ day }}
|
|
97
|
+
|
|
98
|
+
{{ summary.stations }} stations reported, {{ summary.below_zero | length }} below zero.
|
|
99
|
+
The coldest was {{ summary.coldest }}.
|
|
100
|
+
|
|
101
|
+
| Station | Region | Celsius |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
{% for row in summary.rows %}
|
|
104
|
+
| {{ row.station }} | {{ row.region }} | {{ row.celsius }} |
|
|
105
|
+
{% endfor %}
|
|
106
|
+
|
|
107
|
+
save:
|
|
108
|
+
block: storage.write
|
|
109
|
+
depends_on: [page]
|
|
110
|
+
config:
|
|
111
|
+
# ${run.scratch} is the run's own prefix in whatever storage the instance is
|
|
112
|
+
# configured with: a temporary directory under --local, the artifact root on a
|
|
113
|
+
# server, a bucket where an s3 connection serves the scheme.
|
|
114
|
+
target: ${run.scratch}/report.md
|
|
115
|
+
text: ${steps.page.output.text}
|
|
116
|
+
content_type: ${steps.page.output.content_type}
|
|
117
|
+
|
|
118
|
+
back:
|
|
119
|
+
block: storage.read
|
|
120
|
+
depends_on: [save]
|
|
121
|
+
config:
|
|
122
|
+
# Where the write said the object landed, not the same path typed twice.
|
|
123
|
+
source: ${steps.save.output.uri}
|
|
124
|
+
max_size: 1mb
|
|
125
|
+
|
|
126
|
+
show:
|
|
127
|
+
block: log.write
|
|
128
|
+
depends_on: [back]
|
|
129
|
+
config:
|
|
130
|
+
message: the report, read back out of storage
|
|
131
|
+
value: ${steps.back.output.text}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Render a page and POST it to a receiver, which is how a report reaches a chat client or a
|
|
2
|
+
# ticket without anything on this side storing it.
|
|
3
|
+
#
|
|
4
|
+
# In: a batch of load results, some of them rejected.
|
|
5
|
+
# Out: a markdown page delivered to Postman Echo, and what the receiver echoed back.
|
|
6
|
+
#
|
|
7
|
+
# Three hops, and what each one hands on:
|
|
8
|
+
# summary transform.jq, the counts and the identifiers, computed before anything is
|
|
9
|
+
# worded, so the prose can never disagree with the numbers.
|
|
10
|
+
# page report.render, the markdown. Output: text, content_type, text_bytes.
|
|
11
|
+
# deliver webhook.post, a JSON body carrying that text. Output: status, signed,
|
|
12
|
+
# duration_ms, and the receiver's answer.
|
|
13
|
+
#
|
|
14
|
+
# THE BODY IS AN OBJECT, NOT THE PAGE ALONE. webhook.post sends JSON, so the page travels as
|
|
15
|
+
# one field of a small envelope beside the facts a receiver routes on: the pipeline, the day,
|
|
16
|
+
# the severity, and the content type of the text. A receiver that renders markdown reads
|
|
17
|
+
# `text`; one that only routes reads the fields beside it and never parses the page.
|
|
18
|
+
#
|
|
19
|
+
# WHY report.render RATHER THAN A jq STRING. A page built by string concatenation in jq is a
|
|
20
|
+
# page nobody can read in the document. The template is text, indented as it will be
|
|
21
|
+
# rendered, and the numbers in it come from `values` -- and a name that is not there renders
|
|
22
|
+
# empty rather than failing the delivery.
|
|
23
|
+
#
|
|
24
|
+
# THE DELIVERY IS A STEP IN THE DAG, so a rule decides when it runs. This one is `all_done`,
|
|
25
|
+
# so the report is sent whether the work above it succeeded or not: a report that only goes
|
|
26
|
+
# out on success is one that goes quiet exactly when somebody needs it.
|
|
27
|
+
#
|
|
28
|
+
# TO CHANGE IT: -p failures=0 sends the green version, and the severity and the wording both
|
|
29
|
+
# follow from the numbers without another branch in the document.
|
|
30
|
+
#
|
|
31
|
+
# dg run --local examples/recipes/report-to-webhook.yaml
|
|
32
|
+
# dg run --local examples/recipes/report-to-webhook.yaml -p failures=0
|
|
33
|
+
|
|
34
|
+
format: dirigent/v1
|
|
35
|
+
kind: pipeline
|
|
36
|
+
code: report-to-webhook
|
|
37
|
+
name: Render a report and POST it
|
|
38
|
+
description: Render a markdown page from a Jinja template and deliver it to a receiver as one field of a webhook body.
|
|
39
|
+
|
|
40
|
+
tags: [recipes, report, transform, webhook]
|
|
41
|
+
|
|
42
|
+
requires:
|
|
43
|
+
blocks:
|
|
44
|
+
- transform.jq
|
|
45
|
+
- report.render
|
|
46
|
+
- webhook.post
|
|
47
|
+
|
|
48
|
+
connections:
|
|
49
|
+
echo-webhook:
|
|
50
|
+
kind: http
|
|
51
|
+
config:
|
|
52
|
+
base_url: https://postman-echo.com
|
|
53
|
+
timeout: 30s
|
|
54
|
+
health_path: /get
|
|
55
|
+
|
|
56
|
+
params:
|
|
57
|
+
type: object
|
|
58
|
+
properties:
|
|
59
|
+
day:
|
|
60
|
+
type: string
|
|
61
|
+
format: date
|
|
62
|
+
default: "2026-01-01"
|
|
63
|
+
description: The day the load reports on.
|
|
64
|
+
rows:
|
|
65
|
+
type: integer
|
|
66
|
+
minimum: 1
|
|
67
|
+
default: 12
|
|
68
|
+
description: How many records the load handled.
|
|
69
|
+
failures:
|
|
70
|
+
type: integer
|
|
71
|
+
minimum: 0
|
|
72
|
+
default: 3
|
|
73
|
+
description: How many of them were rejected; 0 sends the green version.
|
|
74
|
+
|
|
75
|
+
steps:
|
|
76
|
+
summary:
|
|
77
|
+
block: transform.jq
|
|
78
|
+
config:
|
|
79
|
+
input:
|
|
80
|
+
rows: ${params.rows}
|
|
81
|
+
failures: ${params.failures}
|
|
82
|
+
day: ${params.day}
|
|
83
|
+
program: |
|
|
84
|
+
. as {$rows, $failures, $day}
|
|
85
|
+
| [range($rows)
|
|
86
|
+
| {key: "\($day)/rec-\(.)",
|
|
87
|
+
status: (if . < $failures then "rejected" else "loaded" end)}]
|
|
88
|
+
| {day: $day,
|
|
89
|
+
rows: length,
|
|
90
|
+
loaded: ([.[] | select(.status == "loaded")] | length),
|
|
91
|
+
rejected: ([.[] | select(.status == "rejected")] | length),
|
|
92
|
+
# The keys, so a reader can look them up; not a count of them, which nobody can act on.
|
|
93
|
+
rejected_keys: [.[] | select(.status == "rejected") | .key]}
|
|
94
|
+
| . + {severity: (if .rejected == 0 then "info"
|
|
95
|
+
elif .rejected < (.rows / 2) then "warning"
|
|
96
|
+
else "error" end)}
|
|
97
|
+
|
|
98
|
+
page:
|
|
99
|
+
block: report.render
|
|
100
|
+
depends_on: [summary]
|
|
101
|
+
config:
|
|
102
|
+
values:
|
|
103
|
+
summary: ${steps.summary.output.value}
|
|
104
|
+
content_type: text/markdown
|
|
105
|
+
template: |
|
|
106
|
+
# Load for {{ summary.day }}
|
|
107
|
+
|
|
108
|
+
{{ summary.loaded }} of {{ summary.rows }} records loaded, {{ summary.rejected }} rejected.
|
|
109
|
+
|
|
110
|
+
{% if summary.rejected_keys %}
|
|
111
|
+
Rejected:
|
|
112
|
+
{% for key in summary.rejected_keys %}
|
|
113
|
+
- `{{ key }}`
|
|
114
|
+
{% endfor %}
|
|
115
|
+
{% else %}
|
|
116
|
+
Nothing was rejected.
|
|
117
|
+
{% endif %}
|
|
118
|
+
|
|
119
|
+
deliver:
|
|
120
|
+
block: webhook.post
|
|
121
|
+
depends_on: [page]
|
|
122
|
+
# Sent whether the work above passed or failed.
|
|
123
|
+
rule: all_done
|
|
124
|
+
config:
|
|
125
|
+
connection: echo-webhook
|
|
126
|
+
path: /post
|
|
127
|
+
body:
|
|
128
|
+
pipeline: report-to-webhook
|
|
129
|
+
day: ${params.day}
|
|
130
|
+
severity: ${steps.summary.output.value.severity}
|
|
131
|
+
content_type: ${steps.page.output.content_type}
|
|
132
|
+
text: ${steps.page.output.text}
|
|
133
|
+
headers:
|
|
134
|
+
# Routing the receiver can dispatch on without reading the page.
|
|
135
|
+
x-severity: ${steps.summary.output.value.severity}
|
|
136
|
+
success_status: [200, 202, 204]
|