Skip to content

Commit 75c81fd

Browse files
authored
Instrument Gradle build with metrics. (#214)
* Instrument Gradle build with metrics. Specifically we now report latency and success/failure of each individual Gradle task. * Switch logging to `lifecycle` level. * Add javadoc for DrainingBuildListener. * Update stats dimensions.
1 parent 1eab7fe commit 75c81fd

File tree

9 files changed

+380
-1
lines changed

9 files changed

+380
-1
lines changed

buildSrc/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ dependencies {
3838
implementation "com.jaredsburrows:gradle-license-plugin:0.8.1"
3939
implementation 'digital.wup:android-maven-publish:3.6.2'
4040

41+
implementation 'io.opencensus:opencensus-api:0.18.0'
42+
implementation 'io.opencensus:opencensus-exporter-stats-stackdriver:0.18.0'
43+
runtime 'io.opencensus:opencensus-impl:0.18.0'
44+
4145
implementation 'com.android.tools.build:gradle:3.2.1'
4246
testImplementation 'junit:junit:4.12'
4347
testImplementation 'org.json:json:20180813'
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright 2018 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.ci.metrics;
16+
17+
import org.gradle.BuildAdapter;
18+
import org.gradle.BuildResult;
19+
import org.gradle.api.logging.Logger;
20+
21+
/**
22+
* Build listener that waits for Stackdriver to export metrics before exiting.
23+
*
24+
* <p>Stackdriver exporter is implemented in such a way that it exports metrics on a periodic basis,
25+
* with period being configurable. This means that, when the build finishes and exits, it is highly
26+
* likely that there are unexported metrics in memory. For this reason we have this build listener
27+
* that makes the gradle process sleep for the duration of the configured export period to make sure
28+
* metrics get exported.
29+
*
30+
* @see <a
31+
* href="https://opencensus.io/exporters/supported-exporters/java/stackdriver-stats/">Opencensus
32+
* docs</a>
33+
*/
34+
class DrainingBuildListener extends BuildAdapter {
35+
private final long sleepDuration;
36+
private final Logger logger;
37+
38+
DrainingBuildListener(long sleepDuration, Logger logger) {
39+
this.sleepDuration = sleepDuration;
40+
this.logger = logger;
41+
}
42+
43+
@Override
44+
public void buildFinished(BuildResult result) {
45+
try {
46+
logger.lifecycle("Draining metrics to Stackdriver.");
47+
Thread.sleep(sleepDuration);
48+
} catch (InterruptedException e) {
49+
// Restore the interrupted status
50+
Thread.currentThread().interrupt();
51+
}
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2018 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.ci.metrics;
16+
17+
import java.util.ArrayDeque;
18+
import java.util.HashSet;
19+
import java.util.Queue;
20+
import java.util.Set;
21+
import org.gradle.api.Task;
22+
import org.gradle.api.execution.TaskExecutionGraph;
23+
import org.gradle.api.execution.TaskExecutionListener;
24+
import org.gradle.api.tasks.TaskState;
25+
26+
class MeasuringTaskExecutionListener implements TaskExecutionListener {
27+
private static final String METRICS_START_TIME = "metricsStartTime";
28+
private static final String METRICS_ELAPSED_TIME = "metricsElapsedTime";
29+
30+
private final Metrics metrics;
31+
private final TaskExecutionGraph taskGraph;
32+
33+
MeasuringTaskExecutionListener(Metrics metrics, TaskExecutionGraph taskGraph) {
34+
this.metrics = metrics;
35+
this.taskGraph = taskGraph;
36+
}
37+
38+
@Override
39+
public void beforeExecute(Task task) {
40+
recordStart(task);
41+
}
42+
43+
@Override
44+
public void afterExecute(Task task, TaskState taskState) {
45+
recordElapsed(task);
46+
long elapsedTime = getTotalElapsed(task);
47+
48+
if (taskState.getFailure() != null) {
49+
metrics.measureFailure(task);
50+
return;
51+
}
52+
metrics.measureSuccess(task, elapsedTime);
53+
}
54+
55+
private static void recordStart(Task task) {
56+
task.getExtensions().add(METRICS_START_TIME, System.currentTimeMillis());
57+
}
58+
59+
private static void recordElapsed(Task task) {
60+
long startTime = (long) task.getExtensions().getByName(METRICS_START_TIME);
61+
task.getExtensions().add(METRICS_ELAPSED_TIME, System.currentTimeMillis() - startTime);
62+
}
63+
64+
private static long getElapsed(Task task) {
65+
return (long) task.getExtensions().getByName(METRICS_ELAPSED_TIME);
66+
}
67+
68+
// a tasks elapsed time does not include how long it took for its dependencies took to execute,
69+
// so we walk the dependency graph to get the total elapsed time.
70+
private long getTotalElapsed(Task task) {
71+
Queue<Task> queue = new ArrayDeque<>();
72+
queue.add(task);
73+
Set<Task> visited = new HashSet<>();
74+
75+
long totalElapsed = 0;
76+
while (!queue.isEmpty()) {
77+
Task currentTask = queue.remove();
78+
if (!visited.add(currentTask)) {
79+
continue;
80+
}
81+
82+
totalElapsed += getElapsed(currentTask);
83+
84+
for (Task dep : taskGraph.getDependencies(currentTask)) {
85+
if (!visited.contains(dep)) {
86+
queue.add(dep);
87+
}
88+
}
89+
}
90+
return totalElapsed;
91+
}
92+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2018 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.ci.metrics;
16+
17+
import org.gradle.api.Task;
18+
19+
/** Provides methods for measuring various parts of the build. */
20+
interface Metrics {
21+
/** Measure successful execution of a task. */
22+
void measureSuccess(Task task, long elapsedTime);
23+
24+
/** Measure task execution failure. */
25+
void measureFailure(Task task);
26+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright 2018 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.ci.metrics;
16+
17+
import org.gradle.api.Plugin;
18+
import org.gradle.api.Project;
19+
import org.gradle.api.execution.TaskExecutionGraph;
20+
21+
/** Instruments Gradle to measure latency and success rate of all executed tasks. */
22+
public class MetricsPlugin implements Plugin<Project> {
23+
@Override
24+
public void apply(Project project) {
25+
if (!isCollectionEnabled()) {
26+
project.getLogger().lifecycle("Metrics collection is disabled.");
27+
return;
28+
}
29+
project.getLogger().lifecycle("Metrics collection is enabled.");
30+
31+
Metrics metrics = new StackdriverMetrics(project.getGradle(), project.getLogger());
32+
33+
TaskExecutionGraph taskGraph = project.getGradle().getTaskGraph();
34+
taskGraph.addTaskExecutionListener(new MeasuringTaskExecutionListener(metrics, taskGraph));
35+
}
36+
37+
private static boolean isCollectionEnabled() {
38+
String enabled = System.getenv("FIREBASE_ENABLE_METRICS");
39+
return enabled != null && enabled.equals("1");
40+
}
41+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright 2018 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.ci.metrics;
16+
17+
import io.opencensus.common.Duration;
18+
import io.opencensus.exporter.stats.stackdriver.StackdriverStatsConfiguration;
19+
import io.opencensus.exporter.stats.stackdriver.StackdriverStatsExporter;
20+
import io.opencensus.stats.Aggregation;
21+
import io.opencensus.stats.Measure;
22+
import io.opencensus.stats.Stats;
23+
import io.opencensus.stats.View;
24+
import io.opencensus.tags.TagContext;
25+
import io.opencensus.tags.TagKey;
26+
import io.opencensus.tags.TagValue;
27+
import io.opencensus.tags.Tags;
28+
import io.opencensus.tags.propagation.TagContextBinarySerializer;
29+
import io.opencensus.tags.propagation.TagContextDeserializationException;
30+
import java.io.IOException;
31+
import java.util.Arrays;
32+
import java.util.Base64;
33+
import java.util.List;
34+
import java.util.concurrent.atomic.AtomicBoolean;
35+
import org.gradle.api.GradleException;
36+
import org.gradle.api.Task;
37+
import org.gradle.api.invocation.Gradle;
38+
import org.gradle.api.logging.Logger;
39+
40+
/**
41+
* Object used to record measurements via {@link #measureSuccess(Task, long)} and {@link
42+
* #measureFailure(Task)}.
43+
*/
44+
class StackdriverMetrics implements Metrics {
45+
private static final AtomicBoolean STACKDRIVER_INITIALIZED = new AtomicBoolean();
46+
private static final long STACKDRIVER_UPLOAD_PERIOD_MS = 5000;
47+
48+
private final TagContext globalContext;
49+
private final Logger logger;
50+
51+
private static Measure.MeasureDouble M_LATENCY =
52+
Measure.MeasureDouble.create("latency", "", "ms");
53+
private static Measure.MeasureLong M_SUCCESS = Measure.MeasureLong.create("success", "", "1");
54+
55+
private static final TagKey STAGE = TagKey.create("stage");
56+
private static final TagKey GRADLE_PROJECT = TagKey.create("gradle_project");
57+
58+
private static final List<TagKey> TAG_KEYS =
59+
Arrays.asList(
60+
STAGE,
61+
GRADLE_PROJECT,
62+
TagKey.create("repo_owner"),
63+
TagKey.create("repo_name"),
64+
TagKey.create("pull_number"),
65+
TagKey.create("job_name"),
66+
TagKey.create("build_id"),
67+
TagKey.create("job_type"));
68+
69+
StackdriverMetrics(Gradle gradle, Logger logger) {
70+
this.logger = logger;
71+
globalContext = deserializeContext();
72+
73+
ensureStackdriver(gradle);
74+
75+
Stats.getViewManager()
76+
.registerView(
77+
View.create(
78+
View.Name.create("fireci/tasklatency"),
79+
"The latency in milliseconds",
80+
M_LATENCY,
81+
Aggregation.LastValue.create(),
82+
TAG_KEYS));
83+
84+
Stats.getViewManager()
85+
.registerView(
86+
View.create(
87+
View.Name.create("fireci/tasksuccess"),
88+
"Indicated success or failure.",
89+
M_SUCCESS,
90+
Aggregation.LastValue.create(),
91+
TAG_KEYS));
92+
}
93+
94+
/** Records failure of the execution stage named {@code name}. */
95+
public void measureFailure(Task task) {
96+
97+
TagContext ctx =
98+
Tags.getTagger()
99+
.toBuilder(globalContext)
100+
.put(STAGE, TagValue.create(task.getName()))
101+
.put(GRADLE_PROJECT, TagValue.create(task.getProject().getPath()))
102+
.build();
103+
Stats.getStatsRecorder().newMeasureMap().put(M_SUCCESS, 0).record(ctx);
104+
}
105+
106+
/** Records success and latency of the execution stage named {@code name}. */
107+
public void measureSuccess(Task task, long elapsedTime) {
108+
109+
TagContext ctx =
110+
Tags.getTagger()
111+
.toBuilder(globalContext)
112+
.put(STAGE, TagValue.create(task.getName()))
113+
.put(GRADLE_PROJECT, TagValue.create(task.getProject().getPath()))
114+
.build();
115+
Stats.getStatsRecorder()
116+
.newMeasureMap()
117+
.put(M_SUCCESS, 1)
118+
.put(M_LATENCY, elapsedTime)
119+
.record(ctx);
120+
}
121+
122+
private void ensureStackdriver(Gradle gradle) {
123+
// make sure we only initialize stackdriver once as gradle daemon is not guaranteed to restart
124+
// across gradle invocations.
125+
if (!STACKDRIVER_INITIALIZED.compareAndSet(false, true)) {
126+
logger.lifecycle("Stackdriver exporter already initialized.");
127+
return;
128+
}
129+
logger.lifecycle("Initializing Stackdriver exporter.");
130+
131+
try {
132+
StackdriverStatsExporter.createAndRegister(
133+
StackdriverStatsConfiguration.builder()
134+
.setExportInterval(Duration.fromMillis(STACKDRIVER_UPLOAD_PERIOD_MS))
135+
.build());
136+
137+
// make sure gradle does not exit before metrics get uploaded to stackdriver.
138+
gradle.addBuildListener(new DrainingBuildListener(STACKDRIVER_UPLOAD_PERIOD_MS, logger));
139+
} catch (IOException e) {
140+
throw new GradleException("Could not configure metrics exporter", e);
141+
}
142+
}
143+
144+
/** Extract opencensus context(if any) from environment. */
145+
private static TagContext deserializeContext() {
146+
String serializedContext = System.getenv("OPENCENSUS_STATS_CONTEXT");
147+
if (serializedContext == null) {
148+
return Tags.getTagger().empty();
149+
}
150+
151+
TagContextBinarySerializer serializer = Tags.getTagPropagationComponent().getBinarySerializer();
152+
153+
try {
154+
return serializer.fromByteArray(Base64.getDecoder().decode(serializedContext));
155+
} catch (TagContextDeserializationException e) {
156+
return Tags.getTagger().empty();
157+
}
158+
}
159+
}

ci/fireci/fireci/gradle.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def P(name, value):
2929
return '-P{}={}'.format(name, value)
3030

3131

32+
@stats.measure_call('gradle')
3233
def run(*args, gradle_opts='', workdir=None):
3334
"""Invokes gradle with specified args and gradle_opts."""
3435
new_env = dict(os.environ)

0 commit comments

Comments
 (0)