Skip to content

feat: measure annotations backend #7673

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 5, 2025
Merged

Conversation

AdityaHegde
Copy link
Collaborator

@AdityaHegde AdityaHegde commented Jul 25, 2025

Adds annotation support in metrics view.

  1. Adds annotations key in metrics view yaml.
annotations:
- name: annotation # optional - defaults to model
  model: annotation_source # required
  measures: * # optional - defaults to all. supports selector syntax similar to explore config
- table: annotation_source # alternative way to specify a table directly
  connector: clickhouse # connector for the table
  database: annotation # database for the table
  1. Adds annotation resolver that queries the underlying table. Takes metrics view, annotation, time start/end, optionally a time grain.
  2. MetricsViewAnnotations api for UI to query the annotation. Takes the same args as annotation

Ref PRD: https://www.notion.so/rilldata/Measure-Annotations-9242c8d90da648b88c3b789ae3c67983?d=23bba33c8f5780c6974a001c74bc9f67#23aba33c8f5780a38834c6cb6ee39e4d

Closes PLAT-114

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 4180600 to 9cc2105 Compare July 28, 2025 10:08
@AdityaHegde AdityaHegde marked this pull request as ready for review July 28, 2025 10:20
@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch 3 times, most recently from 0698896 to 50a3f3d Compare July 29, 2025 16:13
@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 50a3f3d to c9625f1 Compare July 29, 2025 16:14
@AdityaHegde AdityaHegde mentioned this pull request Jul 31, 2025
12 tasks
message MetricsViewAnnotationsRequest {
string instance_id = 1;
string metrics_view_name = 2 [(validate.rules).string.min_len = 1];
string annotation_name = 3 [(validate.rules).string.min_len = 1];
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you consider passing a list of measure names, and then returning all relevant annotations for those measures? Not sure if that's desirable, just thought that would more map to the use case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Mapping one backend query to a API request made sense to me. I feel like something like this would be achieved with query batching if and when we integrate it in UI. Open to other opinions.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, so the way I see it, the choice of having one or many underlying models is a bit of an implementation detail. We have also discussed supporting inline annotations in the future, so then there may be many more.

Since annotations are small data (we should ensure there aren't millions of annotations), I just wonder if it would make more sense to return them in one go instead of requiring to iterate over each annotation name in the metrics view spec.

For example, if you put yourself in the place of an LLM using MCP or a customer integrating with our APIs, wouldn't they just expect to make one call (maybe with some filters) and get a list of all the annotations to render in the current context?

Copy link
Collaborator Author

@AdityaHegde AdityaHegde Aug 5, 2025

Choose a reason for hiding this comment

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

Ok, one sense then. We dont really need annotations metadata outside of getting annotation name per measure.

Comment on lines 864 to 867
// Not optional, not null
StructType schema = 1;
// Not optional, not null
repeated google.protobuf.Struct data = 2;
Copy link
Contributor

Choose a reason for hiding this comment

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

Could it be a fixed schema for annotations?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated to use fixed schema. Unknown fields will be put into additional_fields

Comment on lines 531 to 545
data := make([]*structpb.Struct, 0)
for {
row, err := res.Next()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
rowStruct, err := pbutil.ToStruct(row, res.Schema())
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
data = append(data, rowStruct)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Mentioned this earlier, but would it make sense to parse into a more strictly typed response proto here? Or do you prefer it like this?

@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 7f91bb0 to 8d2e820 Compare August 1, 2025 06:53
@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 8d2e820 to e665669 Compare August 1, 2025 10:54
@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from d9139c9 to 48903da Compare August 5, 2025 07:58
Comment on lines 863 to 894
message MetricsViewAnnotationsResponse {
message Annotation {
// Time when the annotation applies. Maps to `time` column from the table.
google.protobuf.Timestamp time = 1;
// Optional. Time when the annotation ends. Only present if the underlying table has the `time_end` column.
optional google.protobuf.Timestamp time_end = 2;
// User defined description of the annotation applies. Maps to `description` column from the table.
string description = 3;
// Minimum grain this annotation is displayed for. Maps to `grain` column from the table.
optional string grain = 4;
// Any other fields are captured here. Will be used in predicates in the future.
google.protobuf.Struct additional_fields = 5;
}

message MeasureAnnotation {
// Not optional, not null
string name = 1;
// Not optional, not null
StructType schema = 2;
// Not optional, not null
repeated Annotation data = 3;
}

message Measure {
// Not optional, not null
string name = 1;
// Not optional, not null
repeated MeasureAnnotation annotations = 2;
}

repeated Measure measures = 1;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about something like:

Suggested change
message MetricsViewAnnotationsResponse {
message Annotation {
// Time when the annotation applies. Maps to `time` column from the table.
google.protobuf.Timestamp time = 1;
// Optional. Time when the annotation ends. Only present if the underlying table has the `time_end` column.
optional google.protobuf.Timestamp time_end = 2;
// User defined description of the annotation applies. Maps to `description` column from the table.
string description = 3;
// Minimum grain this annotation is displayed for. Maps to `grain` column from the table.
optional string grain = 4;
// Any other fields are captured here. Will be used in predicates in the future.
google.protobuf.Struct additional_fields = 5;
}
message MeasureAnnotation {
// Not optional, not null
string name = 1;
// Not optional, not null
StructType schema = 2;
// Not optional, not null
repeated Annotation data = 3;
}
message Measure {
// Not optional, not null
string name = 1;
// Not optional, not null
repeated MeasureAnnotation annotations = 2;
}
repeated Measure measures = 1;
}
message MetricsViewAnnotationsResponse {
message Annotation {
// Description of the annotation. Maps to `description` column from the table.
string description = 3;
// Time when the annotation applies. Maps to `time` column from the table.
google.protobuf.Timestamp time = 1;
// Optional. Time when the annotation ends. Only present if the underlying table has the `time_end` column.
optional google.protobuf.Timestamp time_end = 2;
// Minimum grain this annotation is displayed for. Maps to `grain` column from the table.
optional string grain = 4;
// Any other fields are captured here. Will be used in predicates in the future.
google.protobuf.Struct additional_fields = 5;
// List of measure names that this annotation applies to. If empty, no restrictions apply.
repeated string only_measures = 6;
}
repeated Annotation rows = 1;
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ya works, updated.

@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 623486a to dc70931 Compare August 5, 2025 09:03
observability.AddRequestAttributes(ctx,
attribute.String("args.instance_id", req.InstanceId),
attribute.String("args.metric_view", req.MetricsViewName),
attribute.String("args.annotation", strings.Join(req.Measures, ",")),
Copy link
Contributor

Choose a reason for hiding this comment

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

Key doesn't match values here

Comment on lines 511 to 522
ts, err := queries.ResolveTimestampResult(ctx, s.runtime, req.InstanceId, req.MetricsViewName, mv.ValidSpec.TimeDimension, auth.GetClaims(ctx).SecurityClaims(), int(req.Priority))
if err != nil {
return nil, err
}

// If there are no requested measures, return all measures of the metrics view.
reqMeasures := req.Measures
if len(reqMeasures) == 0 {
for _, mes := range mv.ValidSpec.Measures {
reqMeasures = append(reqMeasures, mes.Name)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The logic of resolving which annotations to fetch for which measures should ideally happen inside the resolver or metricsview package. I.e. the server function should just be a thin wrapper. This enables easier re-use for invocations from other places (e.g. MCP tools).

Can it pass the measures in the metricsview.AnnotationsQuery and delegate all that handling to the metrics view package? Since the goal of the metricsview package is that it contains the logic related to how to consume metrics view specs.

Comment on lines 29 to 42
type annotationsResolverProps struct {
MetricsView string `mapstructure:"metrics_view"`
Annotation string `mapstructure:"annotation"`
}

type annotationsResolverArgs struct {
Priority int `mapstructure:"priority"`
TimeRange *metricsview.TimeRange `mapstructure:"time_range"`
TimeGrain runtimev1.TimeGrain `mapstructure:"time_grain"`
TimeZone string `mapstructure:"time_zone"`
Limit *int64 `mapstructure:"limit"`
Offset *int64 `mapstructure:"offset"`
Timestamps *metricsview.TimestampsResult `mapstructure:"timestamps"`
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Could it take a metricsview.AnnotationsQuery as the properties? Similar to the metrics resolver:

func newMetrics(ctx context.Context, opts *runtime.ResolverOptions) (runtime.Resolver, error) {
qry := &metricsview.Query{}
if err := mapstructureutil.WeakDecode(opts.Properties, qry); err != nil {
return nil, err
}

TimeZone string `mapstructure:"time_zone"`
Limit *int64 `mapstructure:"limit"`
Offset *int64 `mapstructure:"offset"`
Timestamps *metricsview.TimestampsResult `mapstructure:"timestamps"`
Copy link
Contributor

Choose a reason for hiding this comment

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

Can it handle timestamps the same way as the metricsResolver? I.e. look them up inside ResolveInteractive instead of requiring them to be passed in.

The idea of the resolvers is that they provide a key-value based interface that can be queried/cached directly from many places, such as data: sections in alerts/reports/custom APIs, from LLM tool calls, from reconcilers, API handlers, etc.

So internal/computed values should ideally be resolved inside the resolver, not passed in the input.

* Annotations UI: Initial commit

* Add range highlight

* Truncate times

* Add see more feature

* Imrpove hover behaviour

* UXQA

* Fix hover on scroll hiding popover

* Location adjustment

* PR comments
@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 1beccd6 to 2c616c6 Compare August 5, 2025 16:07
@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 2c616c6 to dbab18e Compare August 5, 2025 16:13
Comment on lines +886 to +889
if qry.Limit != nil {
b.WriteString(" LIMIT ?")
args = append(args, *qry.Limit)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

So basically the limit gets applied to each annotation call instead of the combined query? That could lead to issues if multiple annotation models have many rows.

But it's a bit tricky to fix (probably requires a combined SQL query or a pagination token with more state). I'm okay with keeping this for now and fixing when/if this becomes an issue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For the time being we wont have too much of data. Since we query for the selected time range in dashboard this should fine for now.

Comment on lines 107 to 122
tsRes, err := resolveTimestampResult(ctx, r.runtime, r.instanceID, r.query.MetricsView, r.mv.TimeDimension, r.claims, r.query.Priority)
if err != nil {
return nil, err
}

if r.query.TimeRange == nil || r.query.TimeRange.IsZero() {
r.query.TimeRange = &metricsview.TimeRange{
Start: tsRes.Min,
End: tsRes.Max,
}
}

err = r.executor.BindAnnotationsQuery(ctx, r.query, tsRes)
if err != nil {
return nil, err
}
Copy link
Contributor

Choose a reason for hiding this comment

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

If the time range already has a fixed start and end (which I think it often will since the frontend resolves time ranges before making other calls), then it doesn't need to get the timestamps dynamically, right? So should this be moved into a nested case somehow?

Looking in the metricsResolver, seems like a similar issue may be there. Not sure if I'm missing something.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ya we can skip it here. But since dashboard always calls MetricsViewTimeRange this wont give use too much.

Wont change metricsResolver in this PR, dont know if it could lead to issues.

@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from f7dcfa1 to 05ef3c6 Compare August 5, 2025 17:40
@AdityaHegde AdityaHegde force-pushed the feat/annotations-backend branch from 05ef3c6 to e41c48f Compare August 5, 2025 17:51
@AdityaHegde AdityaHegde merged commit 61e8d02 into main Aug 5, 2025
14 checks passed
@AdityaHegde AdityaHegde deleted the feat/annotations-backend branch August 5, 2025 18:06
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.

2 participants