Skip to content

Commit 9296d11

Browse files
AB-xdevmshabarov
andauthored
feat: Env variable and one time message to users for usage stats (#21217)
There is now a GLOBAL opt out in the form of the environment variable. Log message is shown only when executed for the first time. --------- Co-authored-by: Mikhail Shabarov <61410877+mshabarov@users.noreply.github.com> Co-authored-by: mikhail <mikhail@vaadin.com>
1 parent b091938 commit 9296d11

File tree

5 files changed

+282
-1
lines changed

5 files changed

+282
-1
lines changed

flow-server/src/main/java/com/vaadin/flow/server/Constants.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,21 @@ public final class Constants implements Serializable {
150150
*/
151151
public static final boolean DEFAULT_REQUIRE_HOME_NODE_EXECUTABLE = false;
152152

153+
/**
154+
* The name of the environment variable that controls whether server-side
155+
* usage statistics is enabled.
156+
*
157+
* Usage statistics are disabled if the environment variable is set to
158+
* "false".
159+
*/
160+
public static final String VAADIN_USAGE_STATS_ENABLED = "VAADIN_USAGE_STATS_ENABLED";
161+
153162
/**
154163
* The default value for whether usage statistics is enabled.
155164
*/
156-
public static final boolean DEFAULT_DEVMODE_STATS = true;
165+
public static final boolean DEFAULT_DEVMODE_STATS = !"false"
166+
.equalsIgnoreCase(
167+
System.getenv(Constants.VAADIN_USAGE_STATS_ENABLED));
157168

158169
/**
159170
* Internal parameter which prevent validation for annotations which are
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.server;
17+
18+
import java.io.BufferedReader;
19+
import java.io.IOException;
20+
import java.io.InputStreamReader;
21+
import java.util.Map;
22+
23+
import org.junit.Assert;
24+
import org.junit.Test;
25+
26+
/**
27+
* Unit tests for VAADIN_USAGE_STATS_ENABLED environment variable affecting
28+
* Constants.DEFAULT_DEVMODE_STATS.
29+
*/
30+
public class ConstantsUsageStatsEnvTest {
31+
32+
private String runIsolated(Boolean setEnv, String value)
33+
throws IOException, InterruptedException {
34+
String javaHome = System.getProperty("java.home");
35+
String javaBin = javaHome + java.io.File.separator + "bin"
36+
+ java.io.File.separator + "java";
37+
String classpath = System.getProperty("java.class.path");
38+
ProcessBuilder builder = new ProcessBuilder(javaBin, "-cp", classpath,
39+
"com.vaadin.flow.server.PrintDefaultDevModeStatsMain");
40+
41+
Map<String, String> env = builder.environment();
42+
if (setEnv == null || !setEnv) {
43+
// Ensure the environment variable is not present
44+
env.remove(Constants.VAADIN_USAGE_STATS_ENABLED);
45+
} else {
46+
env.put(Constants.VAADIN_USAGE_STATS_ENABLED, value);
47+
}
48+
49+
builder.redirectErrorStream(true);
50+
Process process = builder.start();
51+
try (BufferedReader reader = new BufferedReader(
52+
new InputStreamReader(process.getInputStream()))) {
53+
String line = reader.readLine();
54+
int exit = process.waitFor();
55+
if (exit != 0) {
56+
throw new AssertionError("Child JVM exited with code " + exit
57+
+ "; output: " + line);
58+
}
59+
return line == null ? "" : line.trim();
60+
}
61+
}
62+
63+
@Test
64+
public void whenEnvNotSet_statsEnabledByDefault() throws Exception {
65+
String out = runIsolated(false, null);
66+
Assert.assertEquals("true", out);
67+
}
68+
69+
@Test
70+
public void whenEnvFalse_statsDisabled() throws Exception {
71+
String out = runIsolated(true, "false");
72+
Assert.assertEquals("false", out);
73+
}
74+
75+
@Test
76+
public void whenEnvFALSE_statsDisabled() throws Exception {
77+
String out = runIsolated(true, "FALSE");
78+
Assert.assertEquals("false", out);
79+
}
80+
81+
@Test
82+
public void whenEnvTrue_statsEnabled() throws Exception {
83+
String out = runIsolated(true, "true");
84+
Assert.assertEquals("true", out);
85+
}
86+
87+
@Test
88+
public void whenEnvRandom_statsEnabled() throws Exception {
89+
String out = runIsolated(true, "random-value");
90+
Assert.assertEquals("true", out);
91+
}
92+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.server;
17+
18+
/**
19+
* Helper main class for testing the value of
20+
* {@link Constants#DEFAULT_DEVMODE_STATS} in an isolated JVM where we can
21+
* control the environment variables.
22+
*/
23+
public class PrintDefaultDevModeStatsMain {
24+
25+
public static void main(String[] args) {
26+
System.out.println(Constants.DEFAULT_DEVMODE_STATS);
27+
}
28+
}

vaadin-dev-server/src/main/java/com/vaadin/base/devserver/stats/DevModeUsageStatistics.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616
package com.vaadin.base.devserver.stats;
1717

1818
import java.io.File;
19+
import java.io.IOException;
20+
import java.nio.file.Files;
21+
import java.nio.file.Path;
22+
import java.time.Instant;
1923

2024
import org.slf4j.Logger;
2125
import org.slf4j.LoggerFactory;
2226
import tools.jackson.databind.JsonNode;
2327

2428
import com.vaadin.base.devserver.ServerInfo;
29+
import com.vaadin.flow.server.Constants;
2530
import com.vaadin.flow.server.Version;
2631
import com.vaadin.pro.licensechecker.MachineId;
2732

@@ -85,6 +90,32 @@ public static DevModeUsageStatistics init(File projectFolder,
8590

8691
getLogger().debug("Telemetry enabled");
8792

93+
final Path statisticDirPath = storage.getUsageStatisticsFile()
94+
.getParentFile().toPath();
95+
final Path firstSeenPath = statisticDirPath
96+
.resolve("telemetry-notice-seen.txt");
97+
if (!Files.exists(firstSeenPath)) {
98+
// Inspired by
99+
// https://learn.microsoft.com/en-us/dotnet/core/tools/telemetry#disclosure
100+
getLogger().info("Telemetry");
101+
getLogger().info("---------");
102+
getLogger().info(
103+
"Vaadin collects usage data in order to help us improve your experience. "
104+
+ "You can opt-out of telemetry by setting the {} environment variable value to 'false'.",
105+
Constants.VAADIN_USAGE_STATS_ENABLED);
106+
getLogger().info(
107+
"Read more about Vaadin telemetry at https://vaadin.com/docs/latest/flow/configuration/development-mode#usage-statistics");
108+
109+
try {
110+
Files.createDirectories(statisticDirPath);
111+
Files.writeString(firstSeenPath, Instant.now().toString());
112+
} catch (IOException ioe) {
113+
getLogger().warn(
114+
"Failed to create telemetry notice first seen file",
115+
ioe);
116+
}
117+
}
118+
88119
storage.access(() -> {
89120
instance = new DevModeUsageStatistics(projectFolder, storage);
90121
// Make sure we are tracking the right project
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.base.devserver.stats;
17+
18+
import java.io.ByteArrayOutputStream;
19+
import java.io.File;
20+
import java.io.IOException;
21+
import java.io.PrintStream;
22+
import java.net.URL;
23+
import java.nio.charset.StandardCharsets;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
26+
27+
import org.junit.After;
28+
import org.junit.Assert;
29+
import org.junit.Before;
30+
import org.junit.Test;
31+
import org.mockito.Mockito;
32+
33+
import com.vaadin.flow.testutil.TestUtils;
34+
35+
/**
36+
* Tests that the telemetry notice is logged on first initialization and
37+
* suppressed on subsequent runs when the notice marker file already exists.
38+
*/
39+
public class DevModeUsageStatisticsLoggingTest {
40+
41+
private Path tempDir;
42+
private File usageStatisticsFile;
43+
private StatisticsStorage storage;
44+
private StatisticsSender sender;
45+
46+
private PrintStream originalErr;
47+
private ByteArrayOutputStream capturedErr;
48+
49+
@Before
50+
public void setUp() throws Exception {
51+
// Create an isolated directory for this test so the notice marker file
52+
// does not collide with other tests
53+
tempDir = Files.createTempDirectory("vaadin-telemetry-test-");
54+
tempDir.toFile().deleteOnExit();
55+
56+
usageStatisticsFile = tempDir.resolve("usage-statistics.json").toFile();
57+
copyStatsTemplate(usageStatisticsFile);
58+
59+
storage = Mockito.spy(new StatisticsStorage());
60+
Mockito.when(storage.getUsageStatisticsFile())
61+
.thenReturn(usageStatisticsFile);
62+
63+
sender = Mockito.spy(new StatisticsSender(storage));
64+
Mockito.doAnswer(inv -> null).when(sender)
65+
.triggerSendIfNeeded(Mockito.any());
66+
67+
// Capture slf4j-simple output which goes to System.err by default
68+
originalErr = System.err;
69+
capturedErr = new ByteArrayOutputStream();
70+
System.setErr(new PrintStream(capturedErr, true,
71+
StandardCharsets.UTF_8.name()));
72+
}
73+
74+
@After
75+
public void tearDown() throws Exception {
76+
System.setErr(originalErr);
77+
// Best-effort cleanup of temp dir
78+
try {
79+
Files.walk(tempDir)
80+
.sorted((a, b) -> b.getNameCount() - a.getNameCount())
81+
.forEach(p -> p.toFile().delete());
82+
} catch (IOException ignore) {
83+
}
84+
}
85+
86+
@Test
87+
public void logsTelemetryNoticeOnlyOnFirstRun() {
88+
// First init should log the telemetry notice since the marker file does
89+
// not exist
90+
DevModeUsageStatistics.init(tempDir.toFile(), storage, sender);
91+
String firstRunLogs = capturedErr.toString(StandardCharsets.UTF_8);
92+
Assert.assertTrue("Expected telemetry notice to be logged on first run",
93+
firstRunLogs.contains(
94+
"Vaadin collects usage data in order to help us improve your experience."));
95+
96+
// Clear captured logs
97+
capturedErr.reset();
98+
99+
// Second init should NOT log the notice since the marker file now
100+
// exists
101+
DevModeUsageStatistics.init(tempDir.toFile(), storage, sender);
102+
String secondRunLogs = capturedErr.toString(StandardCharsets.UTF_8);
103+
Assert.assertFalse(
104+
"Telemetry notice must not be logged again after marker file is created",
105+
secondRunLogs.contains(
106+
"Vaadin collects usage data in order to help us improve your experience."));
107+
}
108+
109+
private static void copyStatsTemplate(File target) throws IOException {
110+
URL res = TestUtils
111+
.getTestResource("stats-data/usage-statistics-1.json");
112+
if (res == null) {
113+
throw new IOException(
114+
"Test resource stats-data/usage-statistics-1.json not found");
115+
}
116+
byte[] bytes = Files.readAllBytes(new File(res.getFile()).toPath());
117+
Files.write(target.toPath(), bytes);
118+
}
119+
}

0 commit comments

Comments
 (0)