Skip to content

Commit f45651b

Browse files
feat: download trace (#867)
* feat: download trace
1 parent 2189e8a commit f45651b

File tree

10 files changed

+247
-12
lines changed

10 files changed

+247
-12
lines changed

projects/components/src/download-json/download-json.component.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { IconComponent } from '../icon/icon.component';
99
import { DownloadJsonComponent } from './download-json.component';
1010
import { DownloadJsonModule } from './download-json.module';
1111

12-
describe('Button Component', () => {
12+
describe('Download Json Component', () => {
1313
let spectator: Spectator<DownloadJsonComponent>;
1414
const mockElement = document.createElement('a');
1515
const createElementSpy = jest.fn().mockReturnValue(mockElement);
@@ -53,8 +53,7 @@ describe('Button Component', () => {
5353
spyOn(spectator.component, 'triggerDownload');
5454

5555
expect(spectator.component.dataLoading).toBe(false);
56-
expect(spectator.component.fileName).toBe('download');
57-
expect(spectator.component.tooltip).toBe('Download Json');
56+
expect(spectator.component.fileName).toBe('download.json');
5857
const element = spectator.query('.download-json');
5958
expect(element).toExist();
6059

projects/components/src/download-json/download-json.component.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { NotificationService } from '../notification/notification.service';
1212
changeDetection: ChangeDetectionStrategy.OnPush,
1313
styleUrls: ['./download-json.component.scss'],
1414
template: `
15-
<div class="download-json" [htTooltip]="this.tooltip" (click)="this.triggerDownload()">
15+
<div class="download-json" (click)="this.triggerDownload()">
1616
<ht-button
1717
*ngIf="!this.dataLoading"
1818
class="download-button"
@@ -29,10 +29,7 @@ export class DownloadJsonComponent {
2929
public dataSource!: Observable<unknown>;
3030

3131
@Input()
32-
public fileName: string = 'download';
33-
34-
@Input()
35-
public tooltip: string = 'Download Json';
32+
public fileName: string = 'download.json';
3633

3734
public dataLoading: boolean = false;
3835
private readonly dlJsonAnchorElement: HTMLAnchorElement;
@@ -72,7 +69,7 @@ export class DownloadJsonComponent {
7269
'href',
7370
`data:text/json;charset=utf-8,${encodeURIComponent(data)}`
7471
);
75-
this.renderer.setAttribute(this.dlJsonAnchorElement, 'download', `${this.fileName}.json`);
72+
this.renderer.setAttribute(this.dlJsonAnchorElement, 'download', this.fileName);
7673
this.renderer.setAttribute(this.dlJsonAnchorElement, 'display', 'none');
7774
this.dlJsonAnchorElement.click();
7875
}

projects/components/src/download-json/download-json.module.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ import { NgModule } from '@angular/core';
33
import { ButtonModule } from '../button/button.module';
44
import { IconModule } from '../icon/icon.module';
55
import { NotificationModule } from '../notification/notification.module';
6-
import { TooltipModule } from '../tooltip/tooltip.module';
76
import { DownloadJsonComponent } from './download-json.component';
87

98
@NgModule({
109
declarations: [DownloadJsonComponent],
11-
imports: [CommonModule, ButtonModule, NotificationModule, IconModule, TooltipModule],
10+
imports: [CommonModule, ButtonModule, NotificationModule, IconModule],
1211
exports: [DownloadJsonComponent]
1312
})
1413
export class DownloadJsonModule {}

projects/distributed-tracing/src/pages/trace-detail/trace-detail.page.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ import { TraceDetails, TraceDetailService } from './trace-detail.service';
4343
<div class="separation"></div>
4444
4545
<ht-copy-shareable-link-to-clipboard class="share"></ht-copy-shareable-link-to-clipboard>
46+
47+
<ht-download-json
48+
class="download"
49+
[dataSource]="this.exportSpans$"
50+
fileName="{{ traceDetails.id }}.json"
51+
htTooltip="Download Trace as Json"
52+
></ht-download-json>
4653
</div>
4754
</div>
4855
@@ -60,13 +67,15 @@ export class TraceDetailPageComponent {
6067
public static readonly TRACE_ID_PARAM_NAME: string = 'id';
6168

6269
public readonly traceDetails$: Observable<TraceDetails>;
70+
public readonly exportSpans$: Observable<string>;
6371

6472
public constructor(
6573
private readonly subscriptionLifecycle: SubscriptionLifecycle,
6674
private readonly navigationService: NavigationService,
6775
private readonly traceDetailService: TraceDetailService
6876
) {
6977
this.traceDetails$ = this.traceDetailService.fetchTraceDetails();
78+
this.exportSpans$ = this.traceDetailService.fetchExportSpans();
7079
}
7180

7281
public onDashboardReady(dashboard: Dashboard): void {

projects/distributed-tracing/src/pages/trace-detail/trace-detail.page.module.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { RouterModule } from '@angular/router';
44
import { FormattingModule, TraceRoute } from '@hypertrace/common';
55
import {
66
CopyShareableLinkToClipboardModule,
7+
DownloadJsonModule,
78
IconModule,
89
LabelModule,
910
LoadAsyncModule,
10-
SummaryValueModule
11+
SummaryValueModule,
12+
TooltipModule
1113
} from '@hypertrace/components';
1214
import { NavigableDashboardModule } from '../../shared/dashboard/dashboard-wrapper/navigable-dashboard.module';
1315
import { TracingDashboardModule } from '../../shared/dashboard/tracing-dashboard.module';
@@ -30,9 +32,11 @@ const ROUTE_CONFIG: TraceRoute[] = [
3032
TracingDashboardModule,
3133
IconModule,
3234
SummaryValueModule,
35+
TooltipModule,
3336
LoadAsyncModule,
3437
FormattingModule,
3538
CopyShareableLinkToClipboardModule,
39+
DownloadJsonModule,
3640
NavigableDashboardModule.withDefaultDashboards(traceDetailDashboard)
3741
]
3842
})

projects/distributed-tracing/src/pages/trace-detail/trace-detail.service.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { Observable, Subject } from 'rxjs';
77
import { map, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
88
import { Trace, traceIdKey, TraceType, traceTypeKey } from '../../shared/graphql/model/schema/trace';
99
import { SpecificationBuilder } from '../../shared/graphql/request/builders/specification/specification-builder';
10+
import {
11+
ExportSpansGraphQlQueryHandlerService,
12+
EXPORT_SPANS_GQL_REQUEST
13+
} from '../../shared/graphql/request/handlers/traces/export-spans-graphql-query-handler.service';
1014
import {
1115
TraceGraphQlQueryHandlerService,
1216
TRACE_GQL_REQUEST
@@ -82,6 +86,21 @@ export class TraceDetailService implements OnDestroy {
8286
);
8387
}
8488

89+
public fetchExportSpans(): Observable<string> {
90+
return this.routeIds$.pipe(
91+
switchMap(routeIds =>
92+
this.graphQlQueryService.query<ExportSpansGraphQlQueryHandlerService, string>({
93+
requestType: EXPORT_SPANS_GQL_REQUEST,
94+
traceId: routeIds.traceId,
95+
timestamp: this.dateCoercer.coerce(routeIds.startTime),
96+
limit: 1000
97+
})
98+
),
99+
takeUntil(this.destroyed$),
100+
shareReplay(1)
101+
);
102+
}
103+
85104
private fetchTrace(traceId: string, spanId?: string, startTime?: string | number): Observable<Trace> {
86105
return this.graphQlQueryService.query<TraceGraphQlQueryHandlerService, Trace>({
87106
requestType: TRACE_GQL_REQUEST,

projects/distributed-tracing/src/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export * from './shared/dashboard/widgets/table/table-widget-view-toggle.model';
3838
export * from './shared/services/filter-builder/graphql-filter-builder.service';
3939

4040
// Handlers
41+
export * from './shared/graphql/request/handlers/traces/export-spans-graphql-query-handler.service';
4142
export * from './shared/graphql/request/handlers/traces/trace-graphql-query-handler.service';
4243
export * from './shared/graphql/request/handlers/traces/traces-graphql-query-handler.service';
4344
export * from './shared/graphql/request/handlers/spans/span-graphql-query-handler.service';

projects/distributed-tracing/src/shared/dashboard/data/graphql/graphql-handler-configuration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { SpanGraphQlQueryHandlerService } from '../../../graphql/request/handlers/spans/span-graphql-query-handler.service';
22
import { SpansGraphQlQueryHandlerService } from '../../../graphql/request/handlers/spans/spans-graphql-query-handler.service';
3+
import { ExportSpansGraphQlQueryHandlerService } from '../../../graphql/request/handlers/traces/export-spans-graphql-query-handler.service';
34
import { TraceGraphQlQueryHandlerService } from '../../../graphql/request/handlers/traces/trace-graphql-query-handler.service';
45
import { TracesGraphQlQueryHandlerService } from '../../../graphql/request/handlers/traces/traces-graphql-query-handler.service';
56
import { MetadataGraphQlQueryHandlerService } from '../../../services/metadata/handler/metadata-graphql-query-handler.service';
67

78
export const GRAPHQL_DATA_SOURCE_HANDLER_PROVIDERS = [
9+
ExportSpansGraphQlQueryHandlerService,
810
TracesGraphQlQueryHandlerService,
911
TraceGraphQlQueryHandlerService,
1012
SpansGraphQlQueryHandlerService,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { FixedTimeRange, TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common';
2+
import { GraphQlEnumArgument } from '@hypertrace/graphql-client';
3+
import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest';
4+
import { GraphQlFilterType } from '../../../model/schema/filter/graphql-filter';
5+
import { GraphQlTimeRange } from '../../../model/schema/timerange/graphql-time-range';
6+
import { TRACE_SCOPE } from '../../../model/schema/trace';
7+
import {
8+
ExportSpansGraphQlQueryHandlerService,
9+
EXPORT_SPANS_GQL_REQUEST,
10+
GraphQlExportSpansRequest
11+
} from './export-spans-graphql-query-handler.service';
12+
13+
describe('ExportSpansGraphQlQueryHandlerService', () => {
14+
const createService = createServiceFactory({
15+
service: ExportSpansGraphQlQueryHandlerService,
16+
providers: [
17+
mockProvider(TimeRangeService, {
18+
getCurrentTimeRange: jest
19+
.fn()
20+
.mockReturnValue(new FixedTimeRange(new Date(1568907645141), new Date(1568911245141)))
21+
})
22+
]
23+
});
24+
25+
const testTimeRange = GraphQlTimeRange.fromTimeRange(
26+
new FixedTimeRange(new Date(1568907645141), new Date(1568911245141))
27+
);
28+
const buildRequest = (timestamp?: Date): GraphQlExportSpansRequest => ({
29+
requestType: EXPORT_SPANS_GQL_REQUEST,
30+
traceId: 'test-id',
31+
timestamp: timestamp,
32+
limit: 1
33+
});
34+
35+
test('matches request', () => {
36+
const spectator = createService();
37+
expect(spectator.service.matchesRequest(buildRequest())).toBe(true);
38+
expect(spectator.service.matchesRequest({ requestType: 'other' })).toBe(false);
39+
});
40+
41+
test('produces expected graphql', () => {
42+
const spectator = createService();
43+
const expected = spectator.service.convertRequest(buildRequest());
44+
expect(expected).toEqual({
45+
path: 'exportSpans',
46+
arguments: [
47+
{
48+
name: 'limit',
49+
value: 1
50+
},
51+
{
52+
name: 'between',
53+
value: {
54+
startTime: new Date(testTimeRange.from),
55+
endTime: new Date(testTimeRange.to)
56+
}
57+
},
58+
{
59+
name: 'filterBy',
60+
value: [
61+
{
62+
operator: new GraphQlEnumArgument('EQUALS'),
63+
value: 'test-id',
64+
type: new GraphQlEnumArgument(GraphQlFilterType.Id),
65+
idType: new GraphQlEnumArgument(TRACE_SCOPE)
66+
}
67+
]
68+
}
69+
],
70+
children: [
71+
{
72+
path: 'result'
73+
}
74+
]
75+
});
76+
});
77+
78+
test('produces expected graphql with timestamp', () => {
79+
const spectator = createService();
80+
const traceTimestamp = new Date(new TimeDuration(30, TimeUnit.Minute).toMillis());
81+
const expected = spectator.service.convertRequest(buildRequest(traceTimestamp));
82+
expect(expected).toEqual({
83+
path: 'exportSpans',
84+
arguments: [
85+
{
86+
name: 'limit',
87+
value: 1
88+
},
89+
{
90+
name: 'between',
91+
value: {
92+
startTime: new Date(0),
93+
endTime: new Date(traceTimestamp.getTime() * 2)
94+
}
95+
},
96+
{
97+
name: 'filterBy',
98+
value: [
99+
{
100+
operator: new GraphQlEnumArgument('EQUALS'),
101+
value: 'test-id',
102+
type: new GraphQlEnumArgument(GraphQlFilterType.Id),
103+
idType: new GraphQlEnumArgument(TRACE_SCOPE)
104+
}
105+
]
106+
}
107+
],
108+
children: [
109+
{
110+
path: 'result'
111+
}
112+
]
113+
});
114+
});
115+
116+
test('converts response', () => {
117+
const spectator = createService();
118+
const exportSpansResponse = {
119+
result: '{}'
120+
};
121+
122+
expect(spectator.service.convertResponse(exportSpansResponse)).toEqual('{}');
123+
});
124+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Injectable } from '@angular/core';
2+
import { TimeDuration, TimeRangeService, TimeUnit } from '@hypertrace/common';
3+
import { GraphQlHandlerType, GraphQlQueryHandler, GraphQlSelection } from '@hypertrace/graphql-client';
4+
import { GlobalGraphQlFilterService } from '../../../model/schema/filter/global-graphql-filter.service';
5+
import { GraphQlFilter } from '../../../model/schema/filter/graphql-filter';
6+
import { GraphQlIdFilter } from '../../../model/schema/filter/id/graphql-id-filter';
7+
import { GraphQlTimeRange } from '../../../model/schema/timerange/graphql-time-range';
8+
import { resolveTraceType, TraceType } from '../../../model/schema/trace';
9+
import { GraphQlArgumentBuilder } from '../../builders/argument/graphql-argument-builder';
10+
11+
@Injectable({ providedIn: 'root' })
12+
export class ExportSpansGraphQlQueryHandlerService
13+
implements GraphQlQueryHandler<GraphQlExportSpansRequest, string | undefined> {
14+
public readonly type: GraphQlHandlerType.Query = GraphQlHandlerType.Query;
15+
private readonly argBuilder: GraphQlArgumentBuilder = new GraphQlArgumentBuilder();
16+
17+
public constructor(
18+
private readonly timeRangeService: TimeRangeService,
19+
private readonly globalGraphQlFilterService: GlobalGraphQlFilterService
20+
) {}
21+
22+
public matchesRequest(request: unknown): request is GraphQlExportSpansRequest {
23+
return (
24+
typeof request === 'object' &&
25+
request !== null &&
26+
(request as Partial<GraphQlExportSpansRequest>).requestType === EXPORT_SPANS_GQL_REQUEST
27+
);
28+
}
29+
30+
public convertRequest(request: GraphQlExportSpansRequest): GraphQlSelection {
31+
const timeRange = this.buildTimeRange(request.timestamp);
32+
33+
return {
34+
path: 'exportSpans',
35+
arguments: [
36+
this.argBuilder.forLimit(request.limit),
37+
this.argBuilder.forTimeRange(timeRange),
38+
...this.argBuilder.forFilters(
39+
this.globalGraphQlFilterService.mergeGlobalFilters(resolveTraceType(request.traceType), [
40+
this.buildTraceIdFilter(request)
41+
])
42+
)
43+
],
44+
children: [
45+
{
46+
path: 'result'
47+
}
48+
]
49+
};
50+
}
51+
52+
public convertResponse(response: ExportSpansResponse): string | undefined {
53+
return response.result;
54+
}
55+
56+
private buildTraceIdFilter(request: GraphQlExportSpansRequest): GraphQlFilter {
57+
return new GraphQlIdFilter(request.traceId, resolveTraceType(request.traceType));
58+
}
59+
60+
protected buildTimeRange(timestamp?: Date): GraphQlTimeRange {
61+
const duration = new TimeDuration(30, TimeUnit.Minute);
62+
63+
return timestamp
64+
? new GraphQlTimeRange(timestamp.getTime() - duration.toMillis(), timestamp.getTime() + duration.toMillis())
65+
: GraphQlTimeRange.fromTimeRange(this.timeRangeService.getCurrentTimeRange());
66+
}
67+
}
68+
69+
export const EXPORT_SPANS_GQL_REQUEST = Symbol('GraphQL Export Spans Request');
70+
71+
export interface GraphQlExportSpansRequest {
72+
requestType: typeof EXPORT_SPANS_GQL_REQUEST;
73+
traceType?: TraceType;
74+
traceId: string;
75+
limit: number;
76+
timestamp?: Date;
77+
}
78+
79+
export interface ExportSpansResponse {
80+
result: string;
81+
}

0 commit comments

Comments
 (0)