Skip to content

React UI: Add Exemplar Support to Graph#8832

Merged
juliusv merged 21 commits intoprometheus:mainfrom
LeviHarrison:exemplar-ui
Jun 12, 2021
Merged

React UI: Add Exemplar Support to Graph#8832
juliusv merged 21 commits intoprometheus:mainfrom
LeviHarrison:exemplar-ui

Conversation

@LeviHarrison
Copy link
Contributor

@LeviHarrison LeviHarrison commented May 16, 2021

This PR adds exemplar support to the React UI graph. Exemplars can be:

  • Optionally queried using a checkbox
  • Hovered over and display their labels and their series' labels
  • Filtered out through the regular series selection mechanism

(Partially) implements #8797

One thing that I still can't figure out is this error: Uncaught TypeError: Cannot read property 'save' of null at drawOverlay (:3000/static/js/main.chunk.js:12297) when resizing the graph substantially and other big shifts. It has to do with the exemplar symbol, and as far as I can see it doesn't seem to have any effect. I suppose we could just leave it in, but it may be nice to check for undefined in that section of flot to avoid the message.

Demo

I've found that the fastest way to get exemplars is through Grafana's Observability Demo App Docker Compose Demo. After cloning the repo and following the docker compose setup instructions, go to production/docker-compose and run docker-compose up tempo db app loadgen prometheus. Then start the react dev server in Prometheus. The two queries used in the video are histogram_quantile(.99, sum(rate(tns_request_duration_seconds_bucket{}[1m])) by (le)) (from this article) and tns_request_duration_seconds_bucket.


exemplar-demo.mp4

@LeviHarrison LeviHarrison requested a review from juliusv as a code owner May 16, 2021 03:02
@LeviHarrison LeviHarrison changed the title React UI: Add Exemplar Support React UI: Add Exemplar Support to Graph May 16, 2021
@juliusv
Copy link
Member

juliusv commented May 17, 2021

Awesome, thank you! Gonna take a closer look tomorrow :)

Copy link
Member

@juliusv juliusv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty awesome! A couple of comments, feel free to push back if some stuff I say doesn't make sense.

Separately, it would be great if someone else who has actually worked with exemplars before (@gouthamve?) could give an opinion on not the code, but the actual feature / usage.

throw new Error(query.error || 'invalid response JSON');
}

if (showExemplars) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the intent to group everything graph-related under the switch case above, but at the point where we are actually deciding whether to run a query at all or not, I wonder if it wouldn't be more straightforward to understand if we just omitted the extra variable and just did this here:

Suggested change
if (showExemplars) {
if (this.props.options.type === 'graph' && this.props.showExemplars) {

resultSeries = result.length;
}
let query;
let exemplars;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a side note, we should really start typing these responses, but that's not your job here :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be down to work on that, just in different PR.

}

if (showExemplars) {
exemplars = await fetch(`${this.props.pathPrefix}/${API_PATH}/query_exemplars?${params}`, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, we're sending an extra step parameter here, which currently doesn't cause a problem, but is a bit unclean, as it depends on the current accidental fact that the API doesn't return 400 Bad Request errors when it encounters extra parameters. To still keep things simple and not require a new URLSearchParams object, how about just doing params.delete('step'); before this line? Also kinda makes that explicit to the reader.

}

if (exemplars && exemplars.data) {
query.data.exemplars = exemplars.data;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer using a completely separate exemplars: Exemplar[] state field for the exemplars, because right now query is just a dumb data transfer object that contains exactly the query response as delivered by the API. Adding extra stuff to it that wasn't returned in the same query could be confusing to readers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

...chartData.exemplars.filter(exemplar => {
series: for (const i in selected) {
for (const name in chartData.series[selected[i]].labels) {
if (exemplar.labels[`Series: ${name}`] !== chartData.series[selected[i]].labels[name]) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit too surprising IMO that this depends on the exact magic string formatting (that one would assume would only be consumed by humans) from another place. I wonder if the series labels wouldn't better be stored in a separate set of labels (seriesLabels?) in the exemplar? But just thinking out loud here...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that actually works great! It was my false belief that flot wouldn't allow any fields in the datapoint outside of its defined structure, but this isn't Go...

@cstyan cstyan self-assigned this May 18, 2021
@cstyan
Copy link
Member

cstyan commented May 18, 2021

I can comment on, or get comments from others @ Grafana Labs, regarding usage sometime this week.

@LeviHarrison LeviHarrison requested a review from juliusv May 18, 2021 21:42
@LeviHarrison
Copy link
Contributor Author

One prominent use I see is tracing and the display of traceIDs, or other IDs of that type. It might be useful to be able to directly copy them from the UI, which is currently impossible. I'm not sure what that implementation would look like though.

.map(({ seriesLabels, exemplars }) => {
const newLabels: { [key: string]: string } = {};
for (const label in seriesLabels) {
newLabels[`Series: ${label}`] = seriesLabels[label];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this merging of exemplar labels with series labels here now? I think it'd be cool to not prefix each of them with Series: but modify the tooltip code in GraphHelpers.tsx to have a separate labels section that is only titled "Series labels:" once, and then lists the series labels separately.

@juliusv
Copy link
Member

juliusv commented May 19, 2021

One prominent use I see is tracing and the display of traceIDs, or other IDs of that type. It might be useful to be able to directly copy them from the UI, which is currently impossible. I'm not sure what that implementation would look like though.

Yeah, that would totally make sense. We could install an onclick handler (like in https://www.flotcharts.org/flot/examples/interacting/index.html) and show more permanent info above oder underneath the plot area about the clicked exemplar, including possibly a link to a tracing system (I would imagine in the future that this could be provided by a new command-line flag).

@LeviHarrison
Copy link
Contributor Author

LeviHarrison commented May 19, 2021

My question would be then what would we copy, the names of the labels and their values, or just the values? Only the first, or all the labels? I think it's logical to copy a comma+space separated list of label values and let the user delete what they don't want, but I'm open to other ideas.

@juliusv
Copy link
Member

juliusv commented May 20, 2021

My question would be then what would we copy, the names of the labels and their values, or just the values? Only the first, or all the labels? I think it's logical to copy a comma+space separated list of label values and let the user delete what they don't want, but I'm open to other ideas.

In which context do you mean this? In showing extra info on a trace outside of the graph area?

@LeviHarrison
Copy link
Contributor Author

Ah, I see I misunderstood your previous comment. I thought the onclick handler would be used to copy the content, not to show more information somewhere else. It makes more sense now, thanks :)

@juliusv
Copy link
Member

juliusv commented May 20, 2021

Ah ok, yes :)

Comment on lines +200 to +208
<span style={{ fontSize: '17px' }}>Selected exemplar:</span>
<div className="labels mt-1">
{Object.keys(selectedLabels).map((k, i) => (
<div key={i} style={{ fontSize: '15px' }}>
Copy link
Contributor Author

@LeviHarrison LeviHarrison May 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason, Tailwind wasn't applying these font-sizes (as well as some other properties) so I had to resort to style props.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LeviHarrison We're not using Tailwind, so that might be the reason? We do have a number of CSS / SCSS (for the theme stuff) files though which we use in addition to inline styles. So you could use e.g. https://github.com/prometheus/prometheus/blob/main/web/ui/react-app/src/themes/_shared.scss

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style-wise I'd prefer the font sizes and styles to be similar to the legend items, and instead of floating the exemplar details to the right, how about inserting another full-width section above the legend (equally indented, etc.)? Keeps things simpler / more predictable in the face of shifting window + legend widths.

@LeviHarrison LeviHarrison requested a review from juliusv May 20, 2021 22:36
@LeviHarrison
Copy link
Contributor Author

Here's what the popup looks like

image

@juliusv
Copy link
Member

juliusv commented May 22, 2021

Hmm, I can reliably produce the TypeError: Cannot read property 'save' of null error by simply changing back and forth between by(le) and by(le, method). And also seemingly on almost any other change of the expression input... no idea yet where it comes from, but that's definitely something we need to fix :)

return isNaN(val) ? null : val;
};

const exemplarSymbol = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw. I just played around and find the default points (when omitting the custom symbol function) more beautiful, plus they have a nice hover effect :) Did you mainly switch to a custom method for size reasons (which should be controllable by the radius property)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it was mainly for size reasons. I don't believe you can control the radius per-point, only for all of them (series and exemplars), so the custom symbol was the only option. The square also creates distinction between it and a regular series point.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... so I can definitely increase the radius of only the exemplar circles, and normal series are never drawn as circles, only as line segments, so there should at least be a distinction already.

points: { show: true, radius: 4 },

Gives me:

test-2021-05-23_09.28.53.mp4

The nice thing about this option is that it works with the existing hover plugin and thus meets the user expectation of indicating which exemplar (or other point) you are currently hovering over.

On the custom drawing alternative front I also experimented with making the exemplars a bit more pretty (IMO), like this:

const exemplarSymbol = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
  // Center the symbol on the point.
  y = y - 3.5;

  // Correct if the symbol is overflowing off the grid.
  if (x > ctx.canvas.clientWidth - 59) {
    x = ctx.canvas.clientWidth - 59;
  }
  if (y > ctx.canvas.clientHeight - 37) {
    y = ctx.canvas.clientHeight - 37;
  }

  ctx.translate(x, y);
  ctx.rotate(Math.PI / 4);
  ctx.translate(-x, -y);

  ctx.fillStyle = '#92bce1';
  ctx.fillRect(x, y, 7, 7);

  ctx.strokeStyle = '#0275d8';
  ctx.lineWidth = 1;
  ctx.strokeRect(x, y, 7, 7);
}

That gives us:

test-2021-05-23_09.33.47.mp4

But the downside is that hovering doesn't show a highlight. I guess we could build our own specific hover functionality, like the hover plugin (http://www.flotcharts.org/flot/source/jquery.flot.hover.js) does it for circles.

I think either of those solutions is fine as a start and we shouldn't block the PR merge on more though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird, I think I tried changing the radius and it messed up the lines, but cool that it works! I think that your custom symbols fit in a lot better and look really good, so I'll go ahead and add that in. Hover can definitely come later.

@juliusv
Copy link
Member

juliusv commented May 22, 2021

Hmm, I can reliably produce the TypeError: Cannot read property 'save' of null error

So the good news is, that error seems to even be unrelated to this PR, as I'm getting it on main as well, which is worrying though 🧐 Taking a closer look at that.

@LeviHarrison
Copy link
Contributor Author

Yeah, I've got that one too. The good news is that I haven't seen any actual effect on the graph because of them, and when in a production setup no one will see them anyway (unless they look in the console). I've done some debugging and it seems that an undefined is passed to a function that then tries to call save on it.

It seems that we have flot 0.8.3 while the latest version is 3.0.0, I wonder if upgrading might help alleviate this issue. That being said, it would have to be modified like Grafana's version we use now is. We could also just upgrade to the newer Grafana version. I think I may have tried that while working on this and it didn't resolve anything, but it may be a good idea anyway. I'm not sure how licensing would work though.

@juliusv
Copy link
Member

juliusv commented May 23, 2021

@LeviHarrison Yeah, let's ignore the existing JS error for now then :)

@juliusv
Copy link
Member

juliusv commented May 23, 2021

@cstyan Any news on the usage front? Would be great to get quick feedback from someone actually using exemplars!

@LeviHarrison LeviHarrison force-pushed the exemplar-ui branch 3 times, most recently from 09de725 to d2fa7bb Compare May 23, 2021 13:29
Comment on lines +242 to +275
if (y > ctx.canvas.clientHeight - 40) {
y = ctx.canvas.clientHeight - 40;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to change these values to keep the symbols from overflowing off the bottom, but other than that it's a direct copy of your comment. Looks really good!

LeviHarrison and others added 12 commits June 8, 2021 21:07
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: GitHub <noreply@github.com>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>

Co-authored-by: Julius Volz <julius.volz@gmail.com>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
Signed-off-by: Levi Harrison <git@leviharrison.dev>
@LeviHarrison
Copy link
Contributor Author

LeviHarrison commented Jun 9, 2021

@juliusv It was a quick one so I just got it out of the way. Also, maybe we should rename the showExemplars parameter/option to exemplars to remain consistent with stacked.

@LeviHarrison
Copy link
Contributor Author

@juliusv Just a friendly nudge on this if you have time.

@juliusv
Copy link
Member

juliusv commented Jun 12, 2021

Yep, taking another look now!

@juliusv
Copy link
Member

juliusv commented Jun 12, 2021

Awesome, seems to work great now. The one test seems to be still failing in an unrelated timeout, but I can't re-run it because apparently I don't have the permissions in CircleCI because it's in your private fork. Are you able to rerun it?

@LeviHarrison
Copy link
Contributor Author

Whoops, I had turned CircleCI on for my fork while working on a CI change. Should run on our installation now.

@LeviHarrison
Copy link
Contributor Author

There we go!

@juliusv
Copy link
Member

juliusv commented Jun 12, 2021

👍 Woot, thanks!

@juliusv juliusv merged commit f0fe189 into prometheus:main Jun 12, 2021
@juliusv
Copy link
Member

juliusv commented Jun 12, 2021

I'll try to do some styling follow-ups before the release.

@LeviHarrison
Copy link
Contributor Author

List of (maybe) follow-up things:

  • Fix issue with exemplars removing definition in series because of much greater values
  • Improve styling on exemplar description
  • Improve styling of exemplar points

Other things that spawned from this

  • This error: TypeError: Cannot read property 'save' of null
  • Typing graph API responses

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants