Skip to content
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

Merged
merged 12 commits into from
May 5, 2020
Merged

[APM] Annotations API #64796

merged 12 commits into from
May 5, 2020

Conversation

dgieselaar
Copy link
Member

@dgieselaar dgieselaar commented Apr 29, 2020

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:

  • No public search API. There isn't really a public use case right now (that I know of), and users could always fall back to searching the index directly.
  • annotation.content and annotation.tags have moved to the ECS fields message and tags
  • The index is not created on startup, as the 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

  • Adds a server plugin for Observability.
  • This plugin exposes a factory function that will return an annotations client when annotations are enabled (and by default they are).
  • The annotations client takes care of searching, creating and deleting annotations.
  • The Observability server plugin exposes three (untagged) API endpoints, for creating an annotation, and searching or deleting by id. These endpoints can be accessed from outside of Kibana via API keys or basic auth.
  • When an annotation is created, the index is created on the fly with the requesting user's privileges.
  • Searching for annotations will currently only return the first page, and size is configurable. No pagination is possible.
  • The APM server plugin adds an optional dependency on the Observability server plugin, and adds another endpoint POST /api/apm/services/{serviceName}/annotation that will use the annotations client to create an annotation. It will prefill/override certain fields, like annotation.type, tags and service.name.
  • When the APM App requests annotations, two requests will run in parallel: one that will get the derived deployment annotations, as we have today, and another to get the persisted annotations. If there are any persisted annotations, it will only show those.

@@ -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'>
Copy link
Member Author

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.

Comment on lines +1 to +5
/*
* 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.
*/
Copy link
Member Author

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.

Comment on lines 52 to 59
const [derivedAnnotations, storedAnnotations] = await Promise.all([
getDerivedServiceAnnotations({
setup,
serviceName,
environment
}),
getStoredAnnotations()
]);
Copy link
Member Author

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.

Copy link
Member Author

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']
Copy link
Member Author

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?

Copy link
Member

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.

Copy link
Member

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.

Copy link
Member Author

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?

Copy link
Member

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>>(
Copy link
Member Author

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.

Copy link
Member

@sorenlouv sorenlouv May 4, 2020

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.

Copy link
Member Author

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 @@
/*
Copy link
Member Author

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.

});

return {
search: async (searchParams: SearchParams): Promise<SearchResponse<Annotation>> => {
Copy link
Member Author

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?

Copy link
Member

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.

Copy link
Member Author

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

@dgieselaar dgieselaar marked this pull request as ready for review April 30, 2020 10:00
@dgieselaar dgieselaar requested a review from a team as a code owner April 30, 2020 10:00
@dgieselaar dgieselaar requested a review from a team April 30, 2020 10:00
@dgieselaar dgieselaar requested a review from a team as a code owner April 30, 2020 10:00
@@ -91,10 +95,77 @@ export const serviceAnnotationsRoute = createRoute(() => ({
const { serviceName } = context.params.path;
const { environment } = context.params.query;

let annotationsClient: ScopedAnnotationsClient | undefined;
Copy link
Contributor

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?

Suggested change
let annotationsClient: ScopedAnnotationsClient | undefined;
const annotationsClient = await context.plugins.observability?.getScopedAnnotationsClient(
request
);

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed, thanks!

@andrewvc
Copy link
Contributor

andrewvc commented May 4, 2020

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?

@dgieselaar
Copy link
Member Author

@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',
Copy link
Member

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:

Copy link
Member Author

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,
Copy link
Member

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

Copy link
Member Author

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.

Copy link
Member

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 👍

Comment on lines 71 to 77
filter: filter
.filter(esFilter => !Object.keys(esFilter).includes('range'))
.concat({
term: {
[SERVICE_VERSION]: version
}
})
Copy link
Member

@sorenlouv sorenlouv May 4, 2020

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 )

Copy link
Member Author

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) {
Copy link
Member

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
Copy link
Member

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({
Copy link
Member

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)

Copy link
Member Author

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,
Copy link
Member

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?

Copy link
Member Author

Choose a reason for hiding this comment

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

changed to @timestamp

},
},
},
},
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 quite hard to read - perhaps extract mappings

Copy link
Member Author

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,
Copy link
Member

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.

Copy link
Member Author

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();
Copy link
Member

@sorenlouv sorenlouv May 4, 2020

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 ?

Copy link
Member Author

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);
Copy link
Member

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.

Suggested change
this.initContext.logger.get('annotations').warn(err);
const logger = this.initContext.logger.get('annotations');
logger.warn(err);

Copy link
Member Author

Choose a reason for hiding this comment

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

done

Comment on lines 49 to 51
return annotationsApiPromise
? (await annotationsApiPromise).getScopedAnnotationsClient(...args)
: undefined;
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
return annotationsApiPromise
? (await annotationsApiPromise).getScopedAnnotationsClient(...args)
: undefined;
const api = await annotationsApiPromise;
return api?.getScopedAnnotationsClient(...args);

Copy link
Member Author

Choose a reason for hiding this comment

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

done

});
});
});
}
Copy link
Member

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 :)

@Kerry350 Kerry350 self-requested a review May 5, 2020 10:16
Copy link
Contributor

@Kerry350 Kerry350 left a 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';
Copy link
Contributor

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?

Copy link
Member

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 👍

Copy link
Contributor

@skh skh left a 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!

@kibanamachine
Copy link
Contributor

💚 Build Succeeded

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

@andrewvc
Copy link
Contributor

andrewvc commented May 5, 2020

@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.

@dgieselaar
Copy link
Member Author

@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?

@sorenlouv
Copy link
Member

sorenlouv commented May 5, 2020

@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

@dgieselaar
Copy link
Member Author

dgieselaar commented May 5, 2020

@sqren I don't think it would be particularly hard in terms of implementation. index, which is now a sync getter, would need to become an async operation where it reads from the (user's?) saved objects repository. But, it would probably need some UI, and functional tests, and I don't feel comfortable making a change like that this close to FF. I would prefer us to document any migration scenarios if we should need them.

@dgieselaar
Copy link
Member Author

@andrewvc I've created an issue to track the access control issue. I'll merge this in now given the time constraints (apologies 😃)

@dgieselaar dgieselaar merged commit 399eed7 into elastic:master May 5, 2020
@dgieselaar dgieselaar deleted the annotations-api branch May 5, 2020 17:49
dgieselaar added a commit to dgieselaar/kibana that referenced this pull request May 5, 2020
@andrewvc
Copy link
Contributor

andrewvc commented May 5, 2020

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
apm:test-plan-7.8.0 apm:test-plan-done Pull request that was successfully tested during the test plan release_note:enhancement v7.8.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[APM] Annotations APIs
8 participants