OPENIDE fix statistic

This commit is contained in:
Nikita Iarychenko
2026-09-17 12:44:52 +04:00
parent 78f01ab20a
commit 8b046db2fc
4 changed files with 228 additions and 5 deletions
@@ -1,4 +1,7 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
//
// Modified by Nikita Iarychenko at 2026 as part of the OpenIDE project (https://openide.ru).
// Any modifications are available on the same license terms as the original source code.
package com.intellij.internal.statistic.eventLog.validator.storage
import com.fasterxml.jackson.annotation.JsonInclude
@@ -57,6 +60,7 @@ import com.jetbrains.fus.reporting.model.serialization.SerializationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import org.jetbrains.annotations.ApiStatus
import ru.openide.statistics.OpenIdeFusHttpClient
import tools.jackson.core.JsonGenerator
import tools.jackson.core.StreamReadFeature
import tools.jackson.core.util.DefaultIndenter
@@ -232,7 +236,8 @@ object FusComponentProvider {
val jsonSerializer = FusJacksonSerializer()
val httpClient = applicationInfo.connectionSettings.createJvmHttpClient()
// OpenIDE: the SDK hardcodes the JetBrains configuration URL, see OpenIdeFusHttpClient.
val httpClient = OpenIdeFusHttpClient(applicationInfo.connectionSettings.createJvmHttpClient())
val remoteConfig = DefaultRemoteConfig(
config,
@@ -0,0 +1,110 @@
// OpenIDE Project
// Copyright (C) 2026 “Open Development Platform” Ltd. (https://openide.ru)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3 or later as published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
package ru.openide.statistics
import com.intellij.openapi.diagnostic.logger
import com.jetbrains.fus.reporting.FusHttpClient
import com.jetbrains.fus.reporting.HttpResponse
import org.jetbrains.annotations.ApiStatus
/**
* OpenIDE customization: keeps the bundled `com.jetbrains.fus.reporting` SDK away from the JetBrains
* statistics services.
*
* The SDK composes its recorder-configuration URL itself, from the template hardcoded in
* `com.jetbrains.fus.reporting.configuration.RegionCode`
* (`https://resources.jetbrains.com/storage/fus/config/v4/<recorder>/<product>.json`), so the rebranded
* [com.intellij.internal.statistic.eventLog.connection.ConfigurationClientFactory] — which only the legacy
* upload path goes through — does not apply to it. Without this wrapper the SDK path
* ([com.intellij.internal.statistic.eventLog.validator.storage.FusComponentProvider]) fetched the
* configuration, the metadata and the dictionaries from `resources.jetbrains.com` for every recorder
* (FUS, MP, ML), disclosing the product name, the build number and the client address to JetBrains on
* every start.
*
* No event ever left through that path: the configuration served by JetBrains points `send` at
* `analytics.services.jetbrains.com`, but nothing reads `RemoteConfig.getSendUrl()` — the uploader takes
* its endpoint from the already rebranded legacy path instead. The refusal branch below is therefore a
* guard against that changing, not a plug for an observed leak of collected data.
*
* `ru.openide.io.BlackListUrls` does not cover this: it is consulted only from
* `com.intellij.util.io.HttpRequests`, while the SDK talks over `java.net.http.HttpClient`.
*
* Every request the SDK makes goes through [FusHttpClient], which is why this is the one place worth
* wrapping. Hosts in [REWRITTEN_HOSTS] are replaced with [OPENIDE_STATISTICS_HOST], which mirrors the
* JetBrains URL layout for all recorders, so only the host changes and the path is kept as-is. Any other
* JetBrains host is refused rather than rewritten: the paths there do not match ours, and a request that
* reaches this branch means a code path is still trying to report upstream, which should fail loudly in
* the log instead of being silently repointed at a URL that does not exist.
*/
@ApiStatus.Internal
class OpenIdeFusHttpClient(private val delegate: FusHttpClient) : FusHttpClient {
override fun post(url: String, data: String): HttpResponse {
val target = toOpenIdeUrl(url) ?: return REFUSED
return delegate.post(target, data)
}
override fun get(url: String): HttpResponse {
val target = toOpenIdeUrl(url) ?: return REFUSED
return delegate.get(target)
}
override fun lastModified(url: String): Long {
val target = toOpenIdeUrl(url) ?: return 0
return delegate.lastModified(target)
}
/**
* Returns the URL to request, or `null` if it must not be requested at all.
*/
private fun toOpenIdeUrl(url: String): String? {
val host = hostOf(url) ?: return url
for (rewritten in REWRITTEN_HOSTS) {
if (host.equals(rewritten, ignoreCase = true)) {
return url.replaceFirst(host, OPENIDE_STATISTICS_HOST)
}
}
if (host.equals(JETBRAINS_DOMAIN, ignoreCase = true) || host.endsWith(".$JETBRAINS_DOMAIN", ignoreCase = true)) {
LOG.warn("Refused a statistics request to a JetBrains host: $url")
return null
}
return url
}
private fun hostOf(url: String): String? {
val schemeEnd = url.indexOf("://")
if (schemeEnd < 0) return null
val hostStart = schemeEnd + "://".length
val delimiter = url.indexOfAny(HOST_DELIMITERS, hostStart)
val hostEnd = if (delimiter < 0) url.length else delimiter
return url.substring(hostStart, hostEnd).ifEmpty { null }
}
private companion object {
private val LOG = logger<OpenIdeFusHttpClient>()
private const val OPENIDE_STATISTICS_HOST = "stats.openide.ru"
private const val JETBRAINS_DOMAIN = "jetbrains.com"
private val HOST_DELIMITERS = charArrayOf('/', ':', '?', '#')
/** Hosts whose URL layout `stats.openide.ru` mirrors, so that replacing the host is enough. */
private val REWRITTEN_HOSTS = listOf(
"resources.jetbrains.com",
"resources.jetbrains.com.cn",
)
/** What a refused request looks like to the SDK: a failed response it already knows how to handle. */
private val REFUSED = HttpResponse(statusCode = 403, body = null)
}
}
@@ -0,0 +1,101 @@
// OpenIDE Project
// Copyright (C) 2026 “Open Development Platform” Ltd. (https://openide.ru)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3 or later as published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
package ru.openide.statistics
import com.jetbrains.fus.reporting.FusHttpClient
import com.jetbrains.fus.reporting.HttpResponse
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class OpenIdeFusHttpClientTest {
/** Records the URL the wrapper actually asked for, so that a refused request is visible as `null`. */
private class RecordingClient : FusHttpClient {
var requestedUrl: String? = null
override fun post(url: String, data: String): HttpResponse {
requestedUrl = url
return HttpResponse(200, "")
}
override fun get(url: String): HttpResponse {
requestedUrl = url
return HttpResponse(200, "")
}
override fun lastModified(url: String): Long {
requestedUrl = url
return 1
}
}
private fun get(url: String): Pair<String?, HttpResponse> {
val delegate = RecordingClient()
val response = OpenIdeFusHttpClient(delegate).get(url)
return delegate.requestedUrl to response
}
@Test
fun `configuration url is taken from the OpenIDE host`() {
val (requested, response) = get("https://resources.jetbrains.com/storage/fus/config/v4/FUS/IC.json")
assertThat(requested).isEqualTo("https://stats.openide.ru/storage/fus/config/v4/FUS/IC.json")
assertThat(response.statusCode).isEqualTo(200)
}
@Test
fun `china configuration host is rewritten as well`() {
val (requested, _) = get("https://resources.jetbrains.com.cn/storage/fus/config/v4/ML/IC.json")
assertThat(requested).isEqualTo("https://stats.openide.ru/storage/fus/config/v4/ML/IC.json")
}
@Test
fun `metadata and dictionary paths are kept as-is`() {
val (requested, _) = get("https://resources.jetbrains.com/storage/ap/fus/metadata/dictionaries/FUS/dictionaries.json")
assertThat(requested).isEqualTo("https://stats.openide.ru/storage/ap/fus/metadata/dictionaries/FUS/dictionaries.json")
}
@Test
fun `reporting to the JetBrains analytics service is refused`() {
val (requested, response) = get("https://analytics.services.jetbrains.com/fus/v5/send/")
assertThat(requested).isNull()
assertThat(response.statusCode).isEqualTo(403)
}
@Test
fun `any other JetBrains host is refused`() {
val (requested, response) = get("https://plugins.jetbrains.com/api/search")
assertThat(requested).isNull()
assertThat(response.statusCode).isEqualTo(403)
}
@Test
fun `refused requests report an unknown last modified timestamp`() {
val delegate = RecordingClient()
val lastModified = OpenIdeFusHttpClient(delegate).lastModified("https://uploads.jetbrains.com/x.json")
assertThat(delegate.requestedUrl).isNull()
assertThat(lastModified).isEqualTo(0)
}
@Test
fun `a host that only looks like a JetBrains one is not treated as JetBrains`() {
val url = "https://resources.jetbrains.com.example.org/storage/fus/config/v4/FUS/IC.json"
val (requested, _) = get(url)
assertThat(requested).isEqualTo(url)
}
@Test
fun `OpenIDE hosts are passed through untouched`() {
val (requested, _) = get("https://stats.openide.ru/statistics/fus/v5/send/")
assertThat(requested).isEqualTo("https://stats.openide.ru/statistics/fus/v5/send/")
}
}
@@ -1,9 +1,11 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
//
// Modified by Nikita Iarychenko at 2026 as part of the OpenIDE project (https://openide.ru).
// Any modifications are available on the same license terms as the original source code.
package com.intellij.stats.completion.sender
import com.intellij.ide.ApplicationActivity
import com.intellij.internal.statistic.utils.StatisticsUploadAssistant
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceAsync
@@ -35,9 +37,14 @@ internal class SenderPreloadingActivity : ApplicationActivity {
serviceAsync<RegistryManager>()
if (!Registry.`is`("completion.stats.analytics.platform.send", false)) return
val urlValue = Registry.get("completion.stats.analytics.platform.url")
val statusUrl =
if (urlValue.isChangedFromDefault()) urlValue.asString()
else "https://resources.jetbrains.com/storage/ap/mlcc/config/v1/${ApplicationInfo.getInstance().build.productCode}.json"
// OpenIDE: upstream falls back to https://resources.jetbrains.com/storage/ap/mlcc/config/v1/<product>.json here.
// OpenIDE has no such endpoint and completion logs must not be reported to JetBrains, so the sender stays off
// unless an endpoint is configured explicitly through the registry key.
if (!urlValue.isChangedFromDefault()) {
LOG.info("Completion log sending is disabled: 'completion.stats.analytics.platform.url' is not configured.")
return
}
val statusUrl = urlValue.asString()
// do not check right after the start - avoid getting UsageStatisticsPersistenceComponent too early
delay(5.minutes)