Skip to content

Commit de4a547

Browse files
committed
Implement a PoC jvm agent
This was coded with the help of Opus 4.8
1 parent 47a960a commit de4a547

6 files changed

Lines changed: 159 additions & 1 deletion

File tree

build.sbt

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import play.sbt.routes.RoutesKeys
22
import com.typesafe.sbt.web.SbtWeb.autoImport._
3+
import com.typesafe.sbt.packager.archetypes.JavaAppPackaging.autoImport._
34
import com.gu.Dependencies._
45
import com.gu.ProjectSettings._
56

@@ -14,6 +15,30 @@ https://support.snyk.io/hc/en-us/articles/9590215676189-Deeply-nested-Scala-proj
1415
ThisBuild / asciiGraphWidth := 999999999
1516
ThisBuild / scalaVersion := SCALA_VERSION
1617

18+
/*
19+
* Standalone JVM agent that records which Twirl templates are rendered at runtime.
20+
*
21+
* It is NOT a Play module: it's a plain, pure-Java project assembled into a shaded, self-contained
22+
* jar (with a `Premain-Class` manifest entry) that gets attached to a host app via `-javaagent`.
23+
* ByteBuddy is relocated under com.gu.shaded.bytebuddy so it can never clash with anything on an
24+
* application's classpath (e.g. the byte-buddy that mockito pulls in for tests).
25+
*/
26+
val templateTrackerAgent = Project("template-tracker-agent", file("template-tracker-agent"))
27+
.settings(
28+
crossPaths := false, // pure Java: don't append a _2.13 suffix to the artifact
29+
autoScalaLibrary := false, // pure Java: no scala-library dependency
30+
libraryDependencies += byteBuddy,
31+
assembly / assemblyJarName := "template-tracker-agent.jar",
32+
assembly / assemblyShadeRules := Seq(
33+
ShadeRule.rename("net.bytebuddy.**" -> "com.gu.shaded.bytebuddy.@1").inAll,
34+
),
35+
assembly / packageOptions += Package.ManifestAttributes(
36+
"Premain-Class" -> "com.gu.templatetracker.TemplateTrackerAgent",
37+
"Can-Redefine-Classes" -> "true",
38+
"Can-Retransform-Classes" -> "true",
39+
),
40+
)
41+
1742
val common = library("common")
1843
.settings(
1944
libraryDependencies ++= Seq(
@@ -99,7 +124,20 @@ val sport = application("sport")
99124
),
100125
)
101126

102-
val discussion = application("discussion").dependsOn(commonWithTests).aggregate(common)
127+
val discussion = application("discussion")
128+
.dependsOn(commonWithTests)
129+
.aggregate(common)
130+
.settings(
131+
// --- Twirl template usage tracker (PoC) ---
132+
// Bundle the shaded agent jar into the packaged app under agent/, then have the generated
133+
// native-packager start script attach it via -javaagent. ${app_home} is resolved by the start
134+
// script at launch time, so this needs no change to the CDK/systemd launch config.
135+
Universal / mappings += {
136+
val agentJar = (templateTrackerAgent / assembly).value
137+
agentJar -> "agent/template-tracker-agent.jar"
138+
},
139+
bashScriptExtraDefines += """addJava "-javaagent:${app_home}/../agent/template-tracker-agent.jar"""",
140+
)
103141

104142
val admin = application("admin")
105143
.dependsOn(commonWithTests)

project/Dependencies.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ object Dependencies {
1212
val jerseyVersion = "1.19.4"
1313
val playJsonVersion = "3.0.6"
1414
val apacheCommonsLang = "org.apache.commons" % "commons-lang3" % "3.16.0"
15+
// ByteBuddy powers the standalone template-tracker JVM agent (shaded at assembly time).
16+
// Pinned to 1.15.x: later releases bundle Java 24 multi-release classes that the assembly
17+
// shader can't read/relocate (harmless on our Java 21 runtime, but noisy and not cleanly shaded).
18+
val byteBuddy = "net.bytebuddy" % "byte-buddy" % "1.15.11"
1519
val awsCloudwatch = "software.amazon.awssdk" % "cloudwatch" % awsVersion
1620
val awsDynamodb = "software.amazon.awssdk" % "dynamodb" % awsVersion
1721
val awsKinesis = "software.amazon.awssdk" % "kinesis" % awsVersion

project/plugins.sbt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.11")
1111

1212
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.11.7")
1313

14+
// Used to build the shaded, self-contained template-tracker JVM agent jar
15+
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")
16+
1417
addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.4")
1518

1619
addSbtPlugin("com.github.sbt" % "sbt-git" % "2.1.0")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.gu.templatetracker;
2+
3+
import net.bytebuddy.asm.Advice;
4+
5+
/**
6+
* The advice whose body ByteBuddy inlines at the entry of each instrumented template method.
7+
*
8+
* <p>Keep this minimal: only the {@link Advice.OnMethodEnter} method's bytecode is copied into the
9+
* target. It must reference only types resolvable from the instrumented class's classloader - here
10+
* just {@link TemplateTracker}, which is loaded by the system classloader (the {@code -javaagent}
11+
* jar is appended to the system class path) and therefore visible to Play's child application
12+
* classloader.
13+
*/
14+
public final class RecordTemplateAdvice {
15+
16+
private RecordTemplateAdvice() {}
17+
18+
@Advice.OnMethodEnter
19+
public static void onEnter(@Advice.Origin("#t") String templateClassName) {
20+
TemplateTracker.recordFirstSeen(templateClassName);
21+
}
22+
}
23+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.gu.templatetracker;
2+
3+
import java.util.Set;
4+
import java.util.concurrent.ConcurrentHashMap;
5+
6+
/**
7+
* Holds the set of Twirl templates seen so far in this JVM and logs each one the first time it is
8+
* rendered.
9+
*
10+
* <p>This class is loaded by the system classloader (it lives in the {@code -javaagent} jar) so it
11+
* is shared across the whole application and visible to the woven {@code views.html.*} classes.
12+
*
13+
* <p>The PoC logs to stdout as a single structured JSON line so the ELK pipeline can pick it up.
14+
* We intentionally do not use the application's {@code GuLogging}: that lives in the {@code common}
15+
* module on Play's application classloader and is not (cleanly) reachable from an agent on the
16+
* system classloader without coupling the two together. If we later productionise this, the
17+
* delegate target can be swapped for a registry in {@code common} that emits via {@code GuLogging}
18+
* + CloudWatch + S3/Athena.
19+
*/
20+
public final class TemplateTracker {
21+
22+
private static final Set<String> SEEN = ConcurrentHashMap.newKeySet();
23+
24+
private TemplateTracker() {}
25+
26+
public static void recordFirstSeen(String rawClassName) {
27+
String template = normalise(rawClassName);
28+
// After the first sighting of a template, `add` returns false and we do nothing further -
29+
// so the hot path for high-traffic templates is a single concurrent-set membership check.
30+
if (SEEN.add(template)) {
31+
System.out.println(
32+
"{\"marker\":\"TEMPLATE_FIRST_SEEN\",\"template\":\"" + template + "\"}");
33+
}
34+
}
35+
36+
/** Scala {@code object}s compile to a {@code Foo$} class; strip the trailing {@code $}. */
37+
private static String normalise(String className) {
38+
if (className != null && className.endsWith("$")) {
39+
return className.substring(0, className.length() - 1);
40+
}
41+
return className;
42+
}
43+
}
44+
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.gu.templatetracker;
2+
3+
import net.bytebuddy.agent.builder.AgentBuilder;
4+
import net.bytebuddy.asm.Advice;
5+
import net.bytebuddy.matcher.ElementMatchers;
6+
7+
import java.lang.instrument.Instrumentation;
8+
9+
/**
10+
* A JVM agent that records, once per JVM, the first time each Twirl template is rendered.
11+
*
12+
* <p>It is wired into the host application via {@code -javaagent:.../template-tracker-agent.jar}
13+
* (see the {@code discussion} module in {@code build.sbt}). The JVM calls {@link #premain} before
14+
* the application's {@code main}, i.e. before Play boots and before any {@code views.html.*}
15+
* template class is loaded - so every template is woven naturally as it is first class-loaded.
16+
*
17+
* <p>The instrumentation is deliberately tiny: it inlines a single call at the entry of each
18+
* template's {@code apply}/{@code render} method, which delegates to {@link TemplateTracker} where a
19+
* first-seen {@link java.util.Set} membership check makes every render after the first essentially free.
20+
*/
21+
public final class TemplateTrackerAgent {
22+
23+
private TemplateTrackerAgent() {
24+
}
25+
26+
public static void premain(String args, Instrumentation instrumentation) {
27+
System.out.println(
28+
"{\"marker\":\"TEMPLATE_TRACKER_INIT\",\"message\":\"installing Twirl template usage tracker\"}");
29+
30+
new AgentBuilder.Default()
31+
// Only consider compiled Twirl templates: classes under views.html.* that are
32+
// subtypes of play.twirl.api.Template* (Template0..Template22). This excludes the
33+
// many anonymous-function classes Scala generates inside a template
34+
// (e.g. views.html.fragments.articleBody$$anonfun$...), which also live under
35+
// views.html.* and also have an `apply` method, but are not templates.
36+
.type(
37+
ElementMatchers.<net.bytebuddy.description.type.TypeDescription>nameStartsWith("views.html.")
38+
.and(ElementMatchers.hasSuperType(ElementMatchers.nameStartsWith("play.twirl.api.Template"))))
39+
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
40+
builder.visit(
41+
Advice.to(RecordTemplateAdvice.class)
42+
.on(ElementMatchers.named("apply").or(ElementMatchers.named("render")))))
43+
.installOn(instrumentation);
44+
}
45+
}
46+

0 commit comments

Comments
 (0)