How Prism reads Jira (and why it won't spike your instance)
Written for whoever owns your Jira instance. Short version: your users never generate Jira traffic, and the background sync is a single, serial, incremental, rate-capped job.
1. Users never touch Jira
When someone asks Prism a question, the agent answers from a local read-only copy of the Jira fields it needs, held in Prism's own Postgres database. It does not call Jira at query time — ever. So no matter how many people use the chat or how heavily, Jira sees zero load from user activity. (In the code, the component that serves queries has no HTTP client at all — it only reads Postgres.)
2. The sync is one background job
A single scheduled job refreshes that local copy — by default every 6 hours. It runs one request in flight at a time: no parallelism, no fan-out, no burst. GitHub and Jira syncs are also offset from each other, so they never run together.
3. It's incremental
After a one-time initial load (issues updated in the last INGEST_BACKFILL_DAYS
days — default 90, tunable), every subsequent run pulls only issues changed
since the last run — normally a small delta of a few pages.
The search asks for that window as an age, not as a date: updated >= -370m
rather than a timestamp. A bare JQL date carries no offset, and Jira reads it in
the timezone on the profile of the account the query is made from — the Jira
profile of the credential Prism ingests with, which falls back to your site's
default timezone only if that profile has never set one of its own. Prism's
clock is UTC, so a dated bound would quietly shift by that account's offset and
skip changes whenever the account is west of UTC. A relative bound is evaluated
against Jira's own clock, so Prism never has to know or guess anyone's timezone.
(The one exception is a backfill resuming after an interruption, which continues
from a timestamp Jira itself reported, in the offset Jira reported it in — the
same actor reads the bound that wrote it, which is what makes the round trip
safe.)
Which actor it is matters, because the two settings are configured
independently: an account profile can say America/Chicago on a site whose
default is UTC, and the site default is the one a Jira admin naturally looks
at. Earlier releases of this page said the site's timezone governed. It does
not: we measured it on Jira Cloud, and the measurement is in
Am I affected? below along with a check that settles it on
your own instance without taking our word for anything.
On its first search each run also asks for the same window the old, dated way
and compares where the two lower bounds actually landed. If they agree — as they
do on Jira Cloud and on every Data Center instance we have seen — the run says
so in its log and carries on; the check costs one extra API call per run and is
never repeated per page. If your instance turns out not to honour a relative
date, the run stops, printing both readings, rather than mirroring part of
the window and recording it as all of it. JIRA_ABSOLUTE_WINDOW_BOUND=1
(chart: ingest.jira.absoluteWindowBound) puts the dated bound back so ingest
can run while that is investigated — at the cost of the timezone shift above,
which makes it a temporary lever and not a setting.
Am I affected?
Two populations, and the second one is present tense. Either you ran a
live Jira source on a release before 0.9.0, which sent the dated bound as
the default — that skew is in your history. Or you are running now, on any
release including this one, with ingest.jira.absoluteWindowBound
(JIRA_ABSOLUTE_WINDOW_BOUND=1) turned on: that puts the dated bound back on
purpose, so the skew is being re-created on every cycle for as long as the
hatch stays on. It is a lever and not a setting for exactly that reason.
Otherwise — from 0.9.0, with ingest.jira.absoluteWindowBound off — the window
is an age and the skew is gone, with one exception: a backfill resuming after an
interruption still sends a literal, which is safe because Jira rendered the
stamp it is built from in the same zone it will read it in.
If either population describes you — a pre-0.9.0 live source, or
ingest.jira.absoluteWindowBound on now — two checks, cheapest first, and one
rule for reading them. A material difference in check 2 decides, because it
measures the answer instead of reasoning about it. Check 1 decides only where
check 2 shows no material difference — a quiet project can hide a real skew, so
an inconclusive measurement is not a clearance.
1. What zone is on the ingest credential's own profile? One call. Jira Cloud authenticates with the ingest email address plus the API token as HTTP basic auth; Data Center uses a bearer personal access token (the same split as the pre-flight in Go-live):
# Cloud
curl -sS -u '<email>:<token>' "<your Jira base URL>/rest/api/2/myself"
# Data Center / Server
curl -sS -H "Authorization: Bearer <PAT>" "<your Jira base URL>/rest/api/2/myself"
Read the timeZone field. UTC, Etc/UTC or GMT means nothing on this
account skewed a dated bound — which is check 1's whole answer, so run check 2
before calling it clear. Anything else is an offset — and an offset west of
UTC (anywhere in the Americas) is the direction that loses
data: the dated bound resolves later than intended, so everything updated in
the gap was never asked for, and never asked for again.
Note that this is the account's profile, not serverInfo's
serverTimeZone. The two are set separately and can disagree; on our own test
site they do.
2. The A/B check. Ask Jira the same question twice over the same window — once with a dated bound in UTC wall clock, once as an age — and compare the totals. This is agnostic to whose timezone governs: a material difference means something skewed the window, whatever the reason. Agreement is the narrower claim — nothing skewed it over the rows you just counted (see the honest limit below).
Pick a project the credential can see that has steady traffic, and a window of at least a day:
N=1440 # window in minutes; 1440 = one day
D=$(date -u -d "$N minutes ago" '+%Y-%m-%d %H:%M') # GNU date (Linux)
# D=$(date -u -v-${N}M '+%Y-%m-%d %H:%M') # BSD date (macOS) — use this one instead
echo "$D" # e.g. 2026-08-28 08:10
Cloud counts through /rest/api/3/search/approximate-count (the older
/rest/api/2/search has been removed there). JQL accepts single quotes around a
date literal, which keeps the JSON free of escaping:
# Cloud — a) the dated bound
curl -sS -u '<email>:<token>' -X POST -H 'Content-Type: application/json' \
-d "{\"jql\": \"project = ABC AND updated >= '$D'\"}" \
"<your Jira base URL>/rest/api/3/search/approximate-count"
# Cloud — b) the same instant as an age
curl -sS -u '<email>:<token>' -X POST -H 'Content-Type: application/json' \
-d "{\"jql\": \"project = ABC AND updated >= -${N}m\"}" \
"<your Jira base URL>/rest/api/3/search/approximate-count"
# Data Center — a) the dated bound
curl -sS -H "Authorization: Bearer <PAT>" -X POST -H 'Content-Type: application/json' \
-d "{\"jql\": \"project = ABC AND updated >= '$D'\", \"maxResults\": 0}" \
"<your Jira base URL>/rest/api/2/search" # read `total`
# Data Center — b) the same instant as an age
curl -sS -H "Authorization: Bearer <PAT>" -X POST -H 'Content-Type: application/json' \
-d "{\"jql\": \"project = ABC AND updated >= -${N}m\", \"maxResults\": 0}" \
"<your Jira base URL>/rest/api/2/search" # read `total`
Reading the result. A few rows apart is normal and means nothing, for two
reasons: Jira resolves -Nm to the second at the moment the query arrives while
the dated bound is only minute-granular, so the two name instants a few seconds
apart; and on Cloud approximate-count is what its name says, exact on small
result sets and an estimate on large ones. What you are looking for is a
materially different total — hours of your project's issues, not a handful
of rows.
If they differ materially, the direction tells you which way it hurt:
- age returns more than the dated bound — the account is west of UTC. The dated bound reached back less far than asked. That is under-fetch, and it is the data-losing direction.
- age returns fewer than the dated bound — the account is east of UTC. The dated bound reached back further than asked, which only ever re-fetched rows it already had. Harmless.
One honest limit: near-identical totals prove there was no skew over the rows you just counted. If the project you picked had no issues updated during the band the offset covers, a real skew shows up as almost no difference — so choose a busy project and a window of a day or more, and where check 2 shows no material difference prefer check 1's answer. It does not run the other way: a material difference in check 2 is a measurement, and it decides even where check 1 came back UTC.
If you were affected, set ingest.jira.rewalkFrom to a date before the
source went live and let one cycle re-walk the history. That costs real API
budget and real time, which is why it is worth running the check rather than
assuming either way.
What we measured, and where we did not
On Jira Cloud, the account governs, measured rather than inferred
(2026-08-29). On a site whose serverInfo reported serverTimeZone: Etc/UTC
and whose API account's profile was Asia/Calcutta (+05:30), a project of 125
issues all updated between 20:06Z and 20:17Z on one day answered
updated >= '2026-08-25 01:44' with exactly 91 — precisely the issues at or
after 2026-08-24T20:14Z, which is 01:44 read in the account's +05:30. Read in
the site's Etc/UTC that bound is in the future and the answer would have been
none. A bare updated >= '2026-08-25' returned all 125 for the same reason;
under the site reading it too would have returned none. The updated values
Jira hands back are rendered in that same account offset, which is why Prism's
resume path can format one straight back into JQL.
The A/B check above was run on that same site and reported the skew as designed: over one window the dated bound counted 125 and the age counted 34. The gap is the +05:30 the dated bound reached back further than asked. Over a different window on the same site — one wide enough to contain every issue on both readings — both counted 125, which is the near-identical result that proves nothing and is why the honest limit above is worth reading.
On Jira Server / Data Center we have not measured it — we have no instance to measure. Atlassian documents the same mechanism there (JQL date parsing "considers the user's time zone"), and the standing requests to add a system-timezone option on both deployments presuppose it. Treat that as documentation rather than as our measurement, and let the A/B check above settle it on your instance, which it does regardless of who is right about the actor.
A field Prism starts reading only reaches issues it walks again
Incremental has one consequence worth knowing before you meet it. When a release starts reading a field it did not read before, the field is added to the mirror empty, and an issue already in the mirror gets it only the next time somebody touches that issue in Jira. Nothing re-reads history on its own.
0.11.0 is such a release — it started reading the issue's project. So after upgrading, a per-project breakdown covers only the issues Jira has touched since, and issues with no project are left out of the breakdown while staying in the total on the same answer. Groups that add up to less than the total are that, not a counting error.
To fill it in one pass, re-sweep the history once:
# values.yaml
ingest:
jira:
rewalkFrom: "2026-03-01" # at or before the horizon you care about
It re-walks the ground between that date and where the ingest had got to,
truncating nothing and giving up no coverage you already have. It costs what the
original backfill cost, and it happens once per date — a value left in the
file does not re-sweep on every run. Blank it once the sweep has finished. See
ingest.jira.rewalkFrom.
4. It's scoped, lightweight, and read-only
- Restricted to the project(s) you grant (
JIRA_PROJECTS). A pilot is normally scoped to a single project. - Requests only the fields Prism uses — assignee, issue type, status, created/ resolved dates, story points, sprint, and the issue's project (its key and name) — via Jira's standard search API. Not full issue content.
- Read-only. It only calls
serverInfo,field, andsearch. It never writes to Jira.
5. It backs off under pressure
Every call honours HTTP 429 and the Retry-After header, and retries 5xx
with backoff (up to 60s). If Jira signals load, the job slows itself down.
6. And there's a hard rate cap
ingest.jira.maxRequestsPerMinute sets a fixed ceiling on calls to Jira.
Because the sync is already serial, this is a true requests-per-minute limit — no
burst can exceed it. The shipped default is 30/min — one call every two
seconds (it was 60 before 0.8.0); 0 disables it.
# values.yaml
ingest:
jira:
maxRequestsPerMinute: 30 # the shipped default; raise if the first backfill is too slow
Cloud and Data Center
Jira comes in two editions and Prism reads them two different ways. Jira
Cloud signs in with an account email plus an API token (HTTP basic auth) and
pages through /rest/api/3/search/jql. Jira Data Center / Server signs in
with a bearer personal access token — there is no email — and pages through
/rest/api/2/search. Everything else is the same: the fields, the story-point
discovery, the account-timezone check, the questions.
You say which edition yours is, and Prism checks that you are right. Set
ingest.jira.deployment to cloud (the default) or data_center. On Data
Center, secrets.jiraToken is the personal access token and ingest.jira.email
is not read. The value is never guessed from what you left empty: an empty
email is an empty email, and a Cloud administrator who has not filled it in yet
is told so rather than switched to a bearer token behind their back. What is
checked is that the declaration matches the instance — every ingest run asks
/rest/api/2/serverInfo once before it walks, and the self-test does the same,
and a cloud declaration against an instance that answers Server stops with
both words in the sentence and the value to change. That refusal is the fault
edition_mismatch in the self-test's evidence.
On Admin → Context streams the prebuilt Jira stream carries the same choice
as its deployment parameter, and what the credential form asks for follows
from it: an account email and a token on cloud, a bearer token alone on
data_center. Change the parameter first; the row's own refusal says so if
you do it the other way round.
Upgrading a Data Center install from 0.11 or earlier: two steps, in this
order, and the first one is the one that changes what runs. Set
ingest.jira.deployment: data_center in your values (with ingest.jira.email
empty — the chart refuses the two together) and upgrade. From the next
scheduled Jira run, your Jira ingest moves onto the engine: until now a Data
Center install was the one kind the engine declined and the older lane kept
serving (the self-test's registry disagrees with the legacy lists … legacy
still serves line); with the edition declared, the same scheduled job runs the
engine, with the token as a bearer, against /rest/api/2/search.
The move keeps your data and does not re-copy it. Both lanes record their
progress under the same jira state and write the same table, so the engine's
first run continues from where the older lane stopped — the rows the older lane
wrote stay exactly as they are, and only issues updated since are fetched.
There is no refill to wait for and Jira answers are not interrupted. (An
install with no Jira progress recorded — a first-ever Jira ingest — does
the ordinary first backfill instead.) Measured on Jira Software 9.12.39: with
the older lane's rows and progress in place, the engine's first run added the
new issues and kept every existing row.
It is reversible the same way. Setting ingest.jira.deployment back to
cloud with ingest.jira.email still empty puts the scheduled job back on the
older lane at its next run, which continues from the same recorded progress
with the table untouched. Measured on the same instance: after an engine run,
the revert ran the older lane and every row was still there.
Then, on Admin → Context streams, delete the Jira row: the prebuilt stream
is re-created on the spot from this release's recipe, already set to
data_center from your chart, so there is nothing to register — enter the
personal access token on the new row and enable it. The delete is needed
because a row registered before this release holds a copy of the recipe from
before the Data Center edition existed, and the page reads that copy; the
scheduled tick says so too, as invalid with this exact remedy, rather than
walking a document that signs in the Cloud way. Nothing is lost — the row was
never enabled on a Data Center install, and its credential is the token you are
about to enter again.
What that means in practice
- Steady state: each 6-hourly run fetches only what changed — usually one or two pages, a handful of requests.
- Initial backfill: one project over 90 days is on the order of tens of pages, fetched one every two seconds, done in a couple of minutes — then never again.
- Peak concurrency against Jira: one request. Not one per user, not one per page in parallel — one, full stop.
You hold the levers: the project scope (JIRA_PROJECTS), the backfill window
(INGEST_BACKFILL_DAYS), the schedule, and the hard cap
(maxRequestsPerMinute). If you want it gentler, turn any of them down.