-
Notifications
You must be signed in to change notification settings - Fork 8.3k
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
[APM] Annotations API #64796
[APM] Annotations API #64796
Conversation
@@ -205,7 +205,9 @@ export function getESClient( | |||
index: <Body>(params: APMIndexDocumentParams<Body>) => { | |||
return callEs('index', params); | |||
}, | |||
delete: (params: IndicesDeleteParams): Promise<DeleteDocumentResponse> => { | |||
delete: ( | |||
params: Omit<DeleteDocumentParams, 'type'> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this deletes a document, not an index, so I've added the correct type.
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what we previously had (derived annotations), moved to separate file.
const [derivedAnnotations, storedAnnotations] = await Promise.all([ | ||
getDerivedServiceAnnotations({ | ||
setup, | ||
serviceName, | ||
environment | ||
}), | ||
getStoredAnnotations() | ||
]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might change this to something that will start both requests, but return as soon as the stored annotations are resolved. The aggregation to get the derived annotations can be quite costly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
path: '/api/apm/services/{serviceName}/annotation', | ||
method: 'POST', | ||
options: { | ||
tags: ['access:apm', 'access:apm_write'] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Users can easily work around this by writing to the index directly, but I wanted to keep things consistent with the agent config APIs. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thoughts around the security model? I think this is good. Now at least it's possible to lock down the index and only give api access.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
... oh, you can pass in tags
.. yeah not sure I understand that part.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you mean tags
on an annotation? or tags
on the route?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disregard the above comments. tags
for annotations make sense 👍
export async function bootstrapAnnotations({ index, core, context }: Params) { | ||
const logger = context.logger.get('annotations'); | ||
|
||
function wrapRouteHandler<TType extends t.Type<any>>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs some work I think. Open to suggestions to make it better.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps extract everything related to the API into a create_anotations_api.ts
so it's clear that bootstrap_annotations.ts
creates the API and a returns a client as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved it into register_annotation_apis
(named it as such to distinguish it from create_annotations_client
)
@@ -0,0 +1,108 @@ | |||
/* |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this file was moved from apm
to observability
.
5afd149
to
232c192
Compare
232c192
to
15301b2
Compare
}); | ||
|
||
return { | ||
search: async (searchParams: SearchParams): Promise<SearchResponse<Annotation>> => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now this is just a thin wrapper around _search
. I've added it because we are planning on querying multiple sources in the near future (for instance, the event log). I was talking to @sqren about this and we wonder if we should even have it, or expect consumers of annotations to query the index directly. Having validation when indexing documents is definitely valuable but do we need a (limiting) abstraction around searching as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's better to remove it until there is a need.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed, APM now talks to ES directly to get the stored annotations
@@ -91,10 +95,77 @@ export const serviceAnnotationsRoute = createRoute(() => ({ | |||
const { serviceName } = context.params.path; | |||
const { environment } = context.params.query; | |||
|
|||
let annotationsClient: ScopedAnnotationsClient | undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't you do it like this?
let annotationsClient: ScopedAnnotationsClient | undefined; | |
const annotationsClient = await context.plugins.observability?.getScopedAnnotationsClient( | |
request | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed, thanks!
I'm very excited about annotations! I haven't had a chance to do a deep dive into the code, but I do have some security related questions: Is this space aware at all? Have we discussed the security considerations for annotations made in one space being viewable in another? Were saved objects ruled out here? Is it correct that we will need to update our security docs to include index creation permissions for Kibana observability users? |
@andrewvc It's not space-aware at this point. Saved objects were considered, but if I remember correctly, ECS compatibility and ILM possibilities were some of the reasons why we decided to go with a custom index. Another one could be ease of querying. Indeed documentation should be updated, I'll make sure to sync with @bmorelli25 about what needs to be updated where. |
@@ -28,7 +28,7 @@ const ChartsSyncContextProvider: React.FC = ({ children }) => { | |||
callApmApi => { | |||
if (start && end && serviceName) { | |||
return callApmApi({ | |||
pathname: '/api/apm/services/{serviceName}/annotations', | |||
pathname: '/api/apm/services/{serviceName}/annotation/search', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know this was not introduced in this PR but...
it seems wrong to fetch annotations data here. The responsibility of ChartsSyncContext
was simply to keep the hover state in sync between multiple charts.
What about pulling the annotations fetching out to a separate hook and call it where needed? I suspect it's only two places:
Line 45 in 7002fa8
const syncedChartsProps = useChartsSync(); const syncedChartProps = useChartsSync();
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've thought about this for a bit but I'm not sold. To me, ChartsSyncContext is just a place where we store state common to all charts. I'm not sure if splitting this up would improve things, sounds easy to forget. Do you have any specific concerns, other than responsibility?
index: indices['apm_oss.transactionIndices'], | ||
body: { | ||
size: 0, | ||
track_total_hits: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be the default so should not matter
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default is 10000
rather than false
. See the responses for not setting it and setting it to false
:
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 10000,
"relation" : "gte"
},
"max_score" : null,
"hits" : [ ]
}
}
vs
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"max_score" : null,
"hits" : [ ]
}
}
I'll remove it though, it won't make any significant difference in terms of performance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah, didn't think about that. Good point 👍
filter: filter | ||
.filter(esFilter => !Object.keys(esFilter).includes('range')) | ||
.concat({ | ||
term: { | ||
[SERVICE_VERSION]: version | ||
} | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this would be a lot simpler to read:
[
{ term: { [PROCESSOR_EVENT]: 'transaction' } },
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [SERVICE_VERSION]: version } }
]
(I ❤️ boring, repetitive code )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've now also added slightly more complicated handling of the environment filter. I'll simplify this to two concats, LMK what you think.
}) | ||
).aggregations?.versions.buckets.map(bucket => bucket.key) ?? []; | ||
|
||
if (versions.length > 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mind flipping this around. Putting the short branch first makes it possible to understand the full picture immediately and dedent the second branch
if (versions.length === 0) {
return { annotations: [] };
}
I secretly call the other approach "cliffhanger-code" because I feel like I have to cheat when I jump to the bottom to figure out the ending :p
} | ||
} | ||
}, | ||
track_total_hits: false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not needed (afaik)
SERVICE_VERSION | ||
} from '../../../../common/elasticsearch_fieldnames'; | ||
|
||
export async function getDerivedServiceAnnotations({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps add a comment here that explains what a derived annotation is (compared to a normal annotation)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
return { | ||
type: AnnotationType.VERSION, | ||
id: version, | ||
time: firstSeen, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
don't we normally call this timestamp
? or timestamp.us
to indicate the precision?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
changed to @timestamp
}, | ||
}, | ||
}, | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's quite hard to read - perhaps extract mappings
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
successful: 0, | ||
total: 0, | ||
}, | ||
timed_out: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Faking an ES response like this feels wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed (along with the search
method)
create: async ( | ||
createParams: CreateParams | ||
): Promise<{ _id: string; _index: string; _source: Annotation }> => { | ||
await initIndex(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could it have unintended consequences to update the mappings every time an annotation is created ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll add a indices.exists
call before.
index: config.annotations.index, | ||
context: this.initContext, | ||
}).catch(err => { | ||
this.initContext.logger.get('annotations').warn(err); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think splitting it into two lines will make it look more familiar to most people.
this.initContext.logger.get('annotations').warn(err); | |
const logger = this.initContext.logger.get('annotations'); | |
logger.warn(err); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
return annotationsApiPromise | ||
? (await annotationsApiPromise).getScopedAnnotationsClient(...args) | ||
: undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
return annotationsApiPromise | |
? (await annotationsApiPromise).getScopedAnnotationsClient(...args) | |
: undefined; | |
const api = await annotationsApiPromise; | |
return api?.getScopedAnnotationsClient(...args); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
}); | ||
}); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thorough test 👍 I bet it made you think twice about the API design :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍
Left one minor comment, needn’t hold up merging though.
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
import { CoreSetup, PluginInitializerContext, KibanaRequest } from 'kibana/server'; | ||
import { PromiseReturnType } from '../../../../apm/typings/common'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super minor nit: I think it’d be nice to keep imports from other plugins to a minimum, or from a location that denotes sharing. Could this type be added to the Observability plugin directly? Or exported from the apm
plugin in one of the locations that denotes it’s part of the shared / public contract?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moving PromiseReturnType
to the observability plugin sounds like a good idea 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 from Ingest Management, looking forward to learning more about annotations!
💚 Build SucceededHistory
To update your PR or re-run it, just comment with: |
@dgieselaar What is the access control story we want to tell? It seems odd that we would let people segment their data completely with RBAC and spaces but annotations would cross those boundaries. The additional index maxes sense, but something here feels weird from an administrative standpoint. If I were an operator trying to reason about my indices this one feels like the odd one out. If this is the best option that makes sense, but having just reworked our security docs this adds a whole new dimension of complexity that worries me. |
@andrewvc I think we can make this space-aware in the future by storing the index name in a saved object, both for reading and writing. The APM app is not space aware so I figure we'd start out simple. Do you have any concerns around merging this in for 7.7 (it will be marked as Beta), and figuring out access control later? |
@dgieselaar How hard would it be to make this space-aware for 7.8? I can live without it for now but I just want to make sure this doesn't force us do migration or other things to preserve backwards compatibility when we do add spaces and RBAC |
@sqren I don't think it would be particularly hard in terms of implementation. |
@andrewvc I've created an issue to track the access control issue. I'll merge this in now given the time constraints (apologies 😃) |
I don't think any of the concerns I've raised need to block this PR, but we revisit the decisions here at some point to inform a more ergonomic way of defining privileges in the future. |
Closes #58320.
I've scoped this down a bit to make sure we get this in for 7.8. Mainly, I'm focused on enabling deployment annotations for APM. I'm deviating from the original issue in a couple of places:
annotation.content
andannotation.tags
have moved to the ECS fieldsmessage
andtags
kibana_system
user does not have the appropriate permissions. Instead, the index is created on-the-fly when an annotation is indexed, with the credentials of the user that has triggered the request. This has the added benefit of not creating an index when the user doesn't plan on using annotations.Rough outline
size
is configurable. No pagination is possible.POST /api/apm/services/{serviceName}/annotation
that will use the annotations client to create an annotation. It will prefill/override certain fields, likeannotation.type
,tags
andservice.name
.